File I/O & Serialization

Ruby Intermediate/Advanced — File I/O & Serialization
Ruby Intermediate/Advanced
Course 2 · Chapter 5 · File I/O & Serialization

💾 File I/O & Serialization

Everything so far has lived and died inside a single running program. This chapter covers getting data in and out — reading and writing plain files, and converting Ruby objects into formats that survive being saved to disk or sent across a network, then reconstructed later.

📄 Reading Files

# Read the whole file into one string content = File.read("notes.txt") # Read line by line, memory-efficient for large files File.foreach("notes.txt") do |line| puts line.chomp # .chomp strips the trailing newline end # Read into an array of lines lines = File.readlines("notes.txt")

File.read loads an entire file into memory as one string — fine for small files, wasteful for huge ones. File.foreach streams line by line instead, similar in spirit to Python's for line in open(path) or PHP's fgets loop, without holding the whole file in memory at once.

✍️ Writing Files

# Overwrites the file completely File.write("output.txt", "Hello, file!") # The block form auto-closes the file when done -- the idiomatic choice File.open("output.txt", "w") do |file| file.puts "Line one" file.puts "Line two" end # file is automatically closed here, even if an exception was raised inside
⚠ Always prefer the block form of File.open

File.open("output.txt", "w") without a block returns a file handle you have to remember to .close yourself — forget it, and the file may not be properly flushed to disk, especially if an exception interrupts your code before reaching the close call. The block form guarantees the file closes automatically once the block finishes, even if an exception is raised inside it — conceptually similar to Python's with open(...) as f: context manager, and safer than PHP's manual fopen/fclose pairing.

File modes work the same way across all three languages: "r" read (default), "w" write (truncates existing content), "a" append.

📦 JSON — the Universal Format

require "json" data = { name: "Philip", age: 33, skills: ["Ruby", "PHP"] } json_string = data.to_json puts json_string # => {"name":"Philip","age":33,"skills":["Ruby","PHP"]} parsed = JSON.parse(json_string) parsed["name"] # => "Philip" -- note: string keys, not symbols, after parsing! parsed_symbols = JSON.parse(json_string, symbolize_names: true) parsed_symbols[:name] # => "Philip" -- now symbol keys

JSON is the natural choice for interoperating with literally anything else — an API, a JavaScript frontend, another language entirely. Ruby's require "json" plus .to_json/JSON.parse is directly comparable to PHP's json_encode/json_decode or Python's json.dumps/json.loads.

⚠ JSON round-trips through strings, not symbols

Recall Chapter 2 of Course 1: symbols and strings are genuinely different objects. JSON.parse defaults to string keys, since symbols aren't a real concept in JSON — pass symbolize_names: true explicitly if you want symbol keys back out, and remember any symbol values used going in also come back out as strings.

📜 YAML — Human-Readable Configuration

require "yaml" data = { name: "Philip", age: 33 } puts data.to_yaml # --- # :name: Philip # :age: 33 loaded = YAML.load(data.to_yaml) loaded[:name] # => "Philip" -- YAML preserves symbol keys, unlike JSON

YAML is Ruby's own configuration-file format of choice — Rails' database.yml and Ruby's own Gemfile.lock-adjacent tooling lean on it heavily. Unlike JSON, YAML actually preserves Ruby-specific types like symbols across a round trip, at the cost of being Ruby-specific rather than a universal cross-language standard.

🗃️ Marshal — Ruby-Only Binary Serialization

data = { name: "Philip", scores: [95, 88, 91] } binary = Marshal.dump(data) # serializes to a Ruby-specific binary format restored = Marshal.load(binary) # perfectly reconstructs the original object, including its exact class
⚠ Never Marshal.load data from an untrusted source

Marshal can serialize almost any Ruby object exactly, including custom class instances — a genuine convenience JSON and YAML can't match without extra work. But Marshal.load executes as it reconstructs objects, and loading a maliciously crafted Marshal payload from an untrusted source (a network request, user upload, etc.) is a known way to execute arbitrary code. Keep Marshal for trusted, internal use — caching, temporary files your own program wrote — never for data that came from outside your control.

📜 Serialization Formats, Side by Side

Choosing a Format

FormatCross-language?Preserves symbols/custom classes?Safe for untrusted input?
JSONYes — universalNo — strings onlyYes
YAMLMostly (other languages have YAML libraries)Yes, within RubyOnly with YAML.safe_load
MarshalNo — Ruby onlyYes, exactlyNo — never on untrusted data

💻 Coding Challenges

Challenge 1: Write and Read a File

Use the block form of File.open to write three lines of your choosing to diary.txt. Then use File.foreach to read the file back and print each line prefixed with its line number (starting at 1).

Goal: Practice the safe, block-based pattern for both writing and reading a file.

→ Solution

Challenge 2: JSON Round Trip

Build a hash representing a small user profile with symbol keys (name, email, and an array of tags). Convert it to a JSON string with .to_json, then parse it back with JSON.parse(json_string, symbolize_names: true). Confirm the parsed hash's keys are symbols again by calling .class on one of them.

Goal: See the string-vs-symbol round-trip issue firsthand and how symbolize_names fixes it.

→ Solution

Challenge 3: YAML vs JSON on the Same Data

Take the same hash from Challenge 2 and serialize it both with .to_yaml and .to_json. Print both strings side by side, then load each one back and compare whether the resulting hash's keys are symbols or strings in each case.

Goal: Directly observe the "preserves symbols" difference between the two formats from the comparison table.

→ Solution

💎 Default to JSON Unless You Have a Specific Reason Not To

When in doubt, reach for JSON: it's the safest for untrusted input, the most portable across languages and tools, and the format anything you build is most likely to eventually need to talk to. Save YAML for Ruby-specific configuration files where human readability matters, and treat Marshal as a specialized tool for trusted, same-process or same-machine caching — not a general-purpose serialization default.

🎯 What's Next

Next chapter: Testing with RSpecdescribe/it blocks, expectations, and mocks/stubs, using the block-heavy syntax this course has been building toward since Course 1's final chapter.