Serialization: JSON, YAML, CSV and Marshal

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Ruby 4.0 (4.0.7, checked on 2026-10-18); features newer than Ruby 3.0 name the version that introduced them. A program that keeps data between runs, talks to another program or reads a configuration file has to turn objects into bytes and back again. Ruby ships several ways to do it, and they differ in what they can represent, who can read the result and, above all, in how much trust they demand from the data they read. This page covers JSON (the interchange format of the web, the json default gem), YAML (human-edited configuration, the psych default gem behind the YAML constant), CSV (tabular data, a bundled gem since Ruby 3.4), Marshal (Ruby’s own binary format, in the core), and Array#pack and String#unpack for fixed binary layouts. It ends with a decision guide and a list of the places where older material gives advice that no longer holds.

The thread running through the page is the same question for every format: what does loading do with data I did not write? JSON and CSV only ever produce strings, numbers, booleans, arrays and hashes, so they are safe by construction. YAML and Marshal can name arbitrary classes, which is why the loaders that accept them are the dangerous ones. The security consequences are gathered in Security; reading and writing the files themselves is in IO, Files and Directories; how strings carry encodings is in Encodings and Unicode; and the parsed hashes pair naturally with Pattern Matching. Services that answer HTTP requests with JSON are in Networking and HTTP. For the same ideas in other languages see Go, Rust and Python.

Every example on this page was syntax-checked with ruby -wc, run with ruby -w on Ruby 4.0.7 (json 2.18.0, psych 5.3.1, csv 3.3.5) under a UTF-8 locale and linted with RuboCop using the configuration of the running shelf gem (target Ruby 4.0, double-quoted strings). The single deliberate exception is the cop Security/MarshalLoad, which flags every Marshal.load except the deep-copy idiom, and which is switched off for the examples because loading is their subject. Each # => comment was compared with the real value, and the output shown is the real output. Examples that write files do so inside Dir.mktmpdir, so they leave nothing behind. Each example is self-contained; the ones that use the shelf scenario require "shelf" (see the shelf gem source).

The formats at a glance

Format Library Good for Trust needed to load it

JSON

json (default gem, require "json")

Interchange between programs and languages; web APIs; configuration without comments.

None for JSON.parse. Legacy create_additions can instantiate classes and is off by default.

YAML

psych (default gem, require "yaml")

Human-edited configuration, fixtures, small documents with comments and anchors.

None for YAML.safe_load and YAML.load. Full for YAML.unsafe_load.

CSV

csv (bundled gem since 3.4, require "csv")

Rows and columns, spreadsheets, bulk import and export.

None for parsing. Exported cells can still carry spreadsheet formulas.

Marshal

Core, nothing to require

Caches and IPC between Ruby processes of the same code base; deep copies.

Total. Never load data you did not produce yourself.

pack and unpack

Core (Array#pack, String#unpack)

Fixed binary layouts: headers, network protocols, file formats.

None, but lengths and offsets read from the data must be validated.

The shelf scenario of this reference uses all of them: JSON for the on-disk store, CSV for import and export of the catalogue, YAML for the configuration file and Marshal only as a short-lived cache. The Application Architecture in Plain Ruby page shows where these adapters sit.

JSON

JSON (RFC 8259) has six kinds of value: objects, arrays, strings, numbers, true/false and null. The json gem maps them to Hash, Array, String, Integer/Float, true/false and nil, and back. Ruby 4.0 ships json 2.18.0 (Ruby 3.4 ships 2.9.1, Ruby 3.3 ships 2.7.2). require "json" adds the module functions JSON.parse and JSON.generate and a to_json method on core classes. The module is JSON, its error classes are JSON::ParserError, JSON::GeneratorError and JSON::NestingError (all descend from JSON::JSONError, a StandardError; JSON::NestingError is a subclass of JSON::ParserError).

Parsing: JSON.parse

JSON.parse(source, **options) turns a string into Ruby objects. Keys of objects are strings unless symbolize_names: true is given. Symbolised keys are convenient with dig, with keyword arguments and with hash patterns, and they are safe: unlike the interned symbols of old Rubies, symbols are garbage collected, so a hostile document cannot exhaust memory through key names. JSON.parse accepts any JSON value at the top level, not only an object or an array. A failure raises JSON::ParserError whose message carries the line and column; nil is not a string, so JSON.parse(nil) is a TypeError that you must prevent yourself (a missing HTTP body or an unset environment variable is the usual source).

# frozen_string_literal: true

require "json"

text = '{"isbn": "9780132350884", "title": "Clean Code", "tags": ["craft", "testing"], "stock": {"copies": 3}}'

data = JSON.parse(text)
data["title"] # => "Clean Code"
data.dig("stock", "copies") # => 3

sym = JSON.parse(text, symbolize_names: true)
sym[:tags] # => ["craft", "testing"]
sym.dig(:stock, :copies) # => 3

case sym
in { isbn: String => isbn, stock: { copies: Integer => copies } }
  puts "#{isbn}: #{copies} copies" # prints 9780132350884: 3 copies
end

JSON.parse("2") # => 2
JSON.parse("null") # => nil
JSON.parse('"x"') # => "x"

begin
  JSON.parse('{"a": }')
rescue JSON::ParserError => e
  e.message # => "unexpected character: '}' at line 1 column 7"
end

begin
  JSON.parse("")
rescue JSON::ParserError => e
  e.message # => "unexpected end of input at line 1 column 1"
end

begin
  JSON.parse(nil)
rescue TypeError => e
  e.message # => "no implicit conversion of nil into String"
end

The error message format changed between json versions (Ruby 3.3 says unexpected token at '{"a": }'), so match on the class JSON::ParserError, never on the text. JSON.load_file(path, **options) reads and parses a file, and JSON.parse! / JSON.load_file! are variants with the dangerous defaults (unlimited nesting, allow_nan) that exist for trusted input only.

Generating: generate, pretty_generate and to_json

JSON.generate(object) and object.to_json produce a compact document, and JSON.pretty_generate(object) an indented one (two spaces by default; indent:, space:, object_nl: and array_nl: change the layout). to_json is defined for Hash, Array, String, Integer, Float, true, false and nil. Every other object inherits the generic Object#to_json, which is to_s.to_json: a string with the object’s description in it. That is the most common serialization surprise, and it is the reason Data, Struct, Range, Set and Time all come out as quoted strings.

Rules worth knowing: hash keys are converted with to_s (so nil becomes the empty string, 1 becomes "1" and two keys such as 1 and "1" collide); symbols become strings and cannot be told apart on the way back; NaN and the infinities raise JSON::GeneratorError because JSON has no representation for them; strings must be valid in their encoding (a byte sequence that is not valid UTF-8 raises JSON::GeneratorError, and so does a BINARY string with high bytes, whose message is "\xFF" from ASCII-8BIT to UTF-8; a BINARY string that is pure ASCII is accepted); big integers are written in full (10**30) and are exact in Ruby but may be rounded by a JavaScript reader, which is why identifiers above 2**53 are often sent as strings. strict: true makes the generator reject anything that is not one of the six JSON kinds instead of falling back to to_s, which turns the silent surprise above into an error you can see. script_safe: true escapes / and the line separators U+2028 and U+2029 so the output can be placed inside an HTML <script> element, and ascii_only: true escapes all non-ASCII characters.

# frozen_string_literal: true

require "json"

book = { isbn: "9780132350884", title: "Clean Code", tags: %w[craft testing], note: nil }

JSON.generate(book)
# => "{\"isbn\":\"9780132350884\",\"title\":\"Clean Code\",\"tags\":[\"craft\",\"testing\"],\"note\":null}"
book.to_json.eql?(JSON.generate(book)) # => true
puts JSON.pretty_generate(book)
# {
#   "isbn": "9780132350884",
#   "title": "Clean Code",
#   "tags": [
#     "craft",
#     "testing"
#   ],
#   "note": null
# }

JSON.pretty_generate({ a: [], b: {} }) # => "{\n  \"a\": [],\n  \"b\": {}\n}"
JSON.pretty_generate({ a: [1] }, indent: "\t") # => "{\n\t\"a\": [\n\t\t1\n\t]\n}"

# What each Ruby value becomes.
JSON.generate({ 1 => :one, nil => 2.5, sym: :value })
# => "{\"1\":\"one\",\"\":2.5,\"sym\":\"value\"}"
JSON.generate([Time.at(0).utc, 1..3, 10**30, 1e20])
# => "[\"1970-01-01 00:00:00 UTC\",\"1..3\",1000000000000000000000000000000,1e+20]"

begin
  JSON.generate(Float::NAN)
rescue JSON::GeneratorError => e
  e.message # => "NaN not allowed in JSON"
end

begin
  JSON.generate({ a: Object.new }, strict: true)
rescue JSON::GeneratorError => e
  e.message # => "Object not allowed in JSON"
end

begin
  JSON.generate("caf\xE9")
rescue JSON::GeneratorError => e
  e.message # => "source sequence is illegal/malformed utf-8"
end

JSON.generate("é</script>") # => "\"é</script>\""
JSON.generate("é</script>", script_safe: true) # => "\"é<\\/script>\""
JSON.generate("é", ascii_only: true) # => "\"\\u00e9\""

Times are the other recurring question. Time#to_json gives "1970-01-01 00:00:00 UTC", which is not the ISO 8601 form most APIs expect. Write the format you mean: require "time" and call time.utc.iso8601(3) (or Time.iso8601 to read it back), and keep dates as ISO 8601 strings in the document. DateTime is deprecated and not needed for this (see Time and Dates). Money and other exact decimals should travel as strings or integer cents, not floats: JSON.parse(text, decimal_class: BigDecimal) parses non-integer numbers directly into BigDecimal (the bigdecimal gem is a bundled gem since 3.4, so it needs a Gemfile entry under Bundler).

Serializing your own objects

There is no as_json in plain Ruby (that is a Rails convention). The idiom is two small methods on the class: a to_h that builds the plain hash, and a to_json(*) that forwards to it. Writing to_json(*) rather than to_json matters: JSON.pretty_generate and nested generation pass a state object to every to_json they reach, and an override that drops the argument loses the pretty-printing and the depth tracking. Reading is the mirror image, a class method such as from_h that uses fetch so a missing key is a KeyError with the key’s name instead of a nil that fails far away. Because from_h calls the constructor, the Shelf::Isbn validation runs on the way in: untrusted JSON cannot produce an invalid ISBN.

JSON::Coder (new in the json shipped with Ruby 4.0; Ruby 3.4 does not have it) offers a single place to say what happens to objects JSON does not know: it takes a block that is called for each such object and returns something JSON can represent. The same coder dumps and loads, and its load has none of the legacy additions.

# frozen_string_literal: true

require "json"
require "shelf"

# Data and Struct instances are not hashes: the default to_json is to_s in quotes.
Point = Data.define(:x, :y)
Point.new(x: 1, y: 2).to_json # => "\"#<data Point x=1, y=2>\""

# Teach the value objects to cross the JSON boundary.
module Shelf
  class Book
    def to_h = { isbn: isbn.to_s, title:, author: author.name }
    def to_json(*) = to_h.to_json(*)

    def self.from_h(hash)
      new(isbn: Isbn.new(digits: hash.fetch(:isbn)), title: hash.fetch(:title),
          author: Author.new(name: hash.fetch(:author)))
    end
  end
end

book = Shelf::Book.new(isbn: Shelf::Isbn.new(digits: "978-0-13-235088-4"), title: "Clean Code",
                       author: Shelf::Author.new(name: "Robert Martin"))
json = JSON.generate([book])
# => "[{\"isbn\":\"9780132350884\",\"title\":\"Clean Code\",\"author\":\"Robert Martin\"}]"
puts JSON.pretty_generate([book])
# [
#   {
#     "isbn": "9780132350884",
#     "title": "Clean Code",
#     "author": "Robert Martin"
#   }
# ]

books = JSON.parse(json, symbolize_names: true).map { |h| Shelf::Book.from_h(h) }
books.eql?([book]) # => true

begin
  Shelf::Book.from_h({ isbn: "9780132350884", title: "Clean Code" })
rescue KeyError => e
  e.message # => "key not found: :author"
end

# JSON::Coder (new in the json gem that ships with Ruby 4.0) turns unknown objects into JSON values in one place.
coder = JSON::Coder.new { |object| object.respond_to?(:to_h) ? object.to_h : object.to_s }
coder.dump({ point: Point.new(x: 1, y: 2), at: Time.at(0).utc })
# => "{\"point\":{\"x\":1,\"y\":2},\"at\":\"1970-01-01 00:00:00 UTC\"}"

Options, limits and the json_class trap

JSON.parse is deliberately strict. The options below loosen it for specific, known input.

Option Effect

symbolize_names: true

Hash keys become symbols.

freeze: true

Every parsed object is frozen. Useful for constants and for data shared between Ractors.

max_nesting: n

Raises JSON::NestingError when arrays and objects are nested deeper than n. The default is 100; false or 0 disables the check (never do that for untrusted input, a deeply nested document can exhaust the stack).

allow_nan: true

Accepts the non-standard literals NaN, Infinity and -Infinity.

allow_trailing_comma: true

Accepts [1, 2,] and {"a": 1,} (Ruby 3.4 with json 2.9; Ruby 3.3 raises JSON::ParserError).

allow_duplicate_key: bool

The last value of a repeated key wins. With json 2.18 (Ruby 4.0) a repeated key warns by default, false raises, and true silences the warning. The warning announces that a later json will raise.

decimal_class: BigDecimal

Non-integer numbers are built with this class instead of Float.

object_class:, array_class:

Build objects and arrays with other classes that respond to []= / <<. Avoid OpenStruct (a bundled gem in Ruby 4.0): hashes with symbolize_names: and Data are better.

create_additions: true

Legacy: honour the json_class key and call json_create on the named class. See below.

Generation has matching options: max_nesting, allow_nan, strict, script_safe, ascii_only, indent, space, object_nl and array_nl. JSON.dump(object) and JSON.load(source) are the older, more lenient pair: dump tolerates NaN and writes to an IO when given one; load returns nil for nil or an empty string and accepts a callable that sees every parsed value. Prefer parse/generate in new code; use load only when you need those leniencies.

The trap is the json/add/* family (json/add/range, json/add/time, json/add/set, json/add/struct, json/add/bigdecimal, json/add/core and others). They teach classes to write a "json_class" key and to rebuild themselves from it: with create_additions: true, a document that says "json_class": "Range" is turned into a Range, and any class that defines json_create can be named by the attacker the same way. That is object injection, the JSON counterpart of loading a YAML or Marshal stream. JSON.parse leaves it off, whereas JSON.load switches it on implicitly, which json 2.18 still does while printing a deprecation warning (JSON.load implicit support for create_additions: true is deprecated and will be removed in 3.0, use JSON.unsafe_load or explicitly pass create_additions: true). The rule: never require "json/add/..." in code that parses outside data, and never pass create_additions: true or call JSON.unsafe_load on it. A "json_class" key without the opt-in is plain data.

# frozen_string_literal: true

require "json"
require "bigdecimal"

JSON.parse("[NaN]", allow_nan: true) # => [NaN]
JSON.parse("[1, 2,]", allow_trailing_comma: true) # => [1, 2]
JSON.parse('{"price": 1.10}', decimal_class: BigDecimal) # => {"price" => 0.11e1}
JSON.parse('{"a": 1}', freeze: true).frozen? # => true

begin
  JSON.parse("[[[1]]]", max_nesting: 2)
rescue JSON::NestingError => e
  e.message # => "nesting of 3 is too deep"
end

# Duplicate keys: the last one wins, and json 2.13+ warns (an error from json 3.0).
JSON.parse('{"a": 1, "a": 2}', allow_duplicate_key: true) # => {"a" => 2}

begin
  JSON.parse('{"a": 1, "a": 2}', allow_duplicate_key: false)
rescue JSON::ParserError => e
  e.message # => "duplicate key \"a\" at line 1 column 1"
end

# A "json_class" key is only data unless additions are switched on explicitly.
hostile = '{"json_class": "Range", "a": [1, 2, false]}'
JSON.parse(hostile) # => {"json_class" => "Range", "a" => [1, 2, false]}
JSON.unsafe_load(hostile) # => {"json_class" => "Range", "a" => [1, 2, false]}

require "json/add/range"
JSON.parse(hostile, create_additions: true) # => 1..2
JSON.generate(1..3) # => "{\"json_class\":\"Range\",\"a\":[1,3,false]}"

Large inputs: JSON.parse reads the whole document into memory. For a stream of records use JSON Lines (one document per line, not part of the JSON standard) and parse each line separately with File.foreach(path) { |line| JSON.parse(line) }; there is no streaming parser in the standard library.

YAML

YAML is a superset-ish of JSON designed to be written by people: indentation instead of braces, comments, multi-line strings, anchors and aliases to share parts of a document, and tags that name a type. Ruby’s implementation is Psych, a binding to the C library libyaml (Psych 5.3.1 with libyaml 0.2.5 here); YAML is just another name for Psych. Psych implements most of YAML 1.1, not YAML 1.2, and that difference explains most of the surprises in the section on type guessing below. require "yaml" is enough (psych is a default gem). Object#to_yaml and YAML.dump write documents; YAML.load, YAML.safe_load, YAML.unsafe_load and their _file variants read them.

Dump, load and safe_load

The loaders are where YAML gets dangerous: a YAML document may carry a tag such as !ruby/object:Foo, and the loader will allocate a Foo, set its instance variables and even call methods defined on it. Psych 4.0 (Ruby 3.1) closed this by default: YAML.load now behaves like safe_load with Symbol permitted, and the old behaviour has to be asked for by name, YAML.unsafe_load. Older books and blog posts that call YAML.load on untrusted data as a normal practice describe the unsafe Psych 3 behaviour (Ruby 3.0 and before). The loaders compare as follows.

Method What it accepts Use for

YAML.safe_load

Booleans, numbers, strings, nil, arrays and hashes only. Everything else raises Psych::DisallowedClass. Aliases raise Psych::AliasesNotEnabled unless aliases: true.

Any input you did not write: uploads, API bodies, user configuration.

YAML.load

The same as safe_load but permits Symbol values (and symbol keys).

Files your own program or team wrote, when you want symbols.

YAML.unsafe_load

Anything, including arbitrary Ruby objects through tags.

Round-tripping your own objects from data you produced. Never for outside input.

The extra arguments of safe_load (and of load, which accepts the same keywords) are:

Keyword Meaning

permitted_classes: [Date, Symbol]

Classes that may appear. Date, Time, Symbol, Set, Regexp, Range and your own classes all need listing.

permitted_symbols: [:a]

With Symbol permitted, only these symbols; any other raises Psych::DisallowedClass.

aliases: true

Allows anchors and aliases (&name, *name, and the << merge key).

symbolize_names: true

Hash keys become symbols (like JSON).

freeze: true

Freezes the loaded objects.

filename:

A name for error messages, so Psych::SyntaxError says cfg.yml instead of <unknown>.

fallback:

The value returned for an empty document (YAML.load("") is nil otherwise).

The rule of thumb: use safe_load with an explicit permitted_classes for everything and unsafe_load for nothing. If you need an object of your own class back, convert it from a hash after loading (Book.from_h(hash)), exactly as with JSON, rather than letting the document name classes.

# frozen_string_literal: true

require "yaml"
require "date"

config = { "store" => "memory", "loan_days" => 14, "tags" => %w[fiction poetry], "opened" => nil, "strict" => true }
yaml = config.to_yaml
puts yaml
# ---
# store: memory
# loan_days: 14
# tags:
# - fiction
# - poetry
# opened:
# strict: true

YAML.load(yaml).eql?(config) # => true
YAML.load(yaml).fetch("tags") # => ["fiction", "poetry"]
YAML.load("store: memory", symbolize_names: true) # => {store: "memory"}
YAML.load("") # => nil
YAML.load("", fallback: {}) # => {}
YAML.safe_load("a: 1\na: 2") # => {"a" => 2}

# Symbols are written as ":name" and need an explicit permission with safe_load.
{ name: "Ico", n: nil }.to_yaml # => "---\n:name: Ico\n:n:\n"
YAML.load(":a: 1") # => {a: 1}
begin
  YAML.safe_load(":a: 1")
rescue Psych::DisallowedClass => e
  e.message # => "Tried to load unspecified class: Symbol"
end
YAML.safe_load(":a: 1", permitted_classes: [Symbol]) # => {a: 1}
YAML.safe_load(":a: 1", permitted_symbols: [:a], permitted_classes: [Symbol]) # => {a: 1}

# Dates, times and arbitrary classes are refused until you list them.
begin
  YAML.load("opened: 2024-01-05")
rescue Psych::DisallowedClass => e
  e.message # => "Tried to load unspecified class: Date"
end
YAML.load("opened: 2024-01-05", permitted_classes: [Date]).fetch("opened").to_s # => "2024-01-05"

begin
  YAML.load("--- !ruby/object:Object {}")
rescue Psych::DisallowedClass => e
  e.message # => "Tried to load unspecified class: Object"
end
YAML.unsafe_load("--- !ruby/object:Object {}").class # => Object

begin
  YAML.safe_load("a: 1\nb: [1, 2", filename: "cfg.yml")
rescue Psych::SyntaxError => e
  [e.file, e.line, e.column, e.problem] # => ["cfg.yml", 2, 4, "did not find expected ',' or ']'"]
end

The exceptions form a small family: Psych::Exception (a RuntimeError) is the root, Psych::SyntaxError reports malformed text and carries file, line, column, problem and context, Psych::DisallowedClass reports a refused class or symbol and Psych::BadAlias / Psych::AliasesNotEnabled report an alias that is undefined or not permitted. A missing file raises the usual Errno::ENOENT from YAML.load_file and YAML.safe_load_file. A duplicate key in a mapping is not an error in Psych: the last one wins silently, which hides copy-and-paste mistakes in hand-edited configuration.

Because YAML.load accepts Symbol, a file written with to_yaml from a hash with symbol keys (:name: Ico, with a leading colon) reads back with symbol keys. That is a Ruby-only dialect: a Python or JavaScript consumer sees the colon as part of the key. Dump string keys (config.transform_keys(&:to_s).to_yaml) for files other tools read.

Anchors, aliases and merge keys

An anchor (&defaults) names a node and an alias (*defaults) reuses it, which removes repetition in configuration files such as CI definitions and Rails database.yml. The merge key <<: *defaults copies the anchored mapping into the current one, and keys written after it override the copied ones. Psych resolves the merge key; plain YAML 1.2 does not define it. The same machinery is why aliases are off by default in safe_load: a document of nested aliases can expand to an enormous structure (the "billion laughs" attack), so aliases: true belongs to files you trust. When dumping, Psych writes an anchor wherever the same object appears twice, and reading it with unsafe_load or aliases: true restores the sharing, as the last lines of the example show.

# frozen_string_literal: true

require "yaml"

doc = <<~YAML
  defaults: &defaults
    loan_days: 14
    renewals: 2
  reading_room:
    <<: *defaults
    loan_days: 0
  members:
    <<: *defaults
YAML

begin
  YAML.safe_load(doc)
rescue Psych::AliasesNotEnabled => e
  e.message # => "Alias parsing was not enabled. To enable it, pass `aliases: true` to `Psych::load` or `Psych::safe_load`."
end

settings = YAML.safe_load(doc, aliases: true)
settings["reading_room"] # => {"loan_days" => 0, "renewals" => 2}
settings["members"] # => {"loan_days" => 14, "renewals" => 2}
Psych::AliasesNotEnabled.ancestors.take(3) # => [Psych::AliasesNotEnabled, Psych::BadAlias, Psych::Exception]

# Shared objects are written as anchors and read back as one object.
shared = { "mode" => "ro" }
dumped = { "a" => shared, "b" => shared }.to_yaml
puts dumped
# ---
# a: &1
#   mode: ro
# b: *1

copy = YAML.unsafe_load(dumped)
copy["a"].equal?(copy["b"]) # => true

# Several documents in one stream.
YAML.load_stream("--- 1\n--- two\n") # => [1, "two"]
YAML.dump_stream({ "a" => 1 }, [2]) # => "---\na: 1\n---\n- 2\n"

Type guessing and the YAML 1.1 surprises

An unquoted YAML scalar is typed by its appearance, and Psych follows YAML 1.1: yes, no, on, off, y and n in several capitalisations are booleans, ~ and an empty value are nil, 010 is octal 8, 0x1f is hexadecimal, underscores are digit separators, 1:30 is a base-60 number (5400) and 2024-01-05 is a Date. The classic casualty is the country code NO, which loads as false (the "Norway problem"). Version numbers written as 1.10 load as the float 1.1. The defence is to quote anything that is text ("1.10", "NO", "0123") and to check types after loading; Psych does the reverse when dumping and quotes any string that would be misread, which is why YAML.dump("code" => "0123") writes '0123'. A single-letter or boolean-looking key such as n is even quoted as a key.

Objects dump through their instance variables by default; a class can control the document by defining encode_with(coder) (write entries into the coder hash-like object) and init_with(coder) (read them back), which is how to keep caches and handles out of a file. Struct dumps as !ruby/struct:Name. Data dumps as !ruby/data:Name with the Psych that ships with Ruby 4.0; Ruby 3.4’s Psych 5.2.2 wrote an empty !ruby/object:Name {} and lost the members, so a YAML file with Data instances is not portable to older Rubies. Set dumps as !ruby/object:Set with a hash: mapping. None of these tags can be loaded by safe_load unless the class is permitted, and for a Struct or Data the member names are symbols, so Symbol has to be permitted too.

# frozen_string_literal: true

require "yaml"

# YAML 1.1 (Psych) reads plain scalars by guessing their type.
guessed = YAML.safe_load(<<~YAML)
  a: yes
  b: no
  c: NO
  d: 010
  e: 0x1f
  f: 1_000
  g: 1:30
  h: ~
  i: 1.10
  j: "1.10"
  k: 08
YAML
guessed.values_at("a", "b", "c", "d") # => [true, false, false, 8]
guessed.values_at("e", "f", "g", "h") # => [31, 1000, 5400, nil]
guessed.values_at("i", "j", "k") # => [1.1, "1.10", "08"]

# Strings that would be misread are quoted when dumped.
YAML.dump("code" => "0123") # => "---\ncode: '0123'\n"
YAML.dump("flag" => "no") # => "---\nflag: 'no'\n"
YAML.dump("n" => "yes") # => "---\n\"n\": 'yes'\n"
YAML.dump("text" => "two\nlines\n") # => "---\ntext: |\n  two\n  lines\n"

# Objects: instance variables by default, or take over with encode_with and init_with.
class Token
  attr_reader :value

  def initialize(value) = @value = value
  def encode_with(coder) = coder["value"] = value
  def init_with(coder) = @value = coder["value"]
end

text = Token.new("placeholder-token").to_yaml # => "--- !ruby/object:Token\nvalue: placeholder-token\n"
YAML.unsafe_load(text).value # => "placeholder-token"
YAML.safe_load(text, permitted_classes: [Token]).value # => "placeholder-token"

# Struct and Data (the Data format needs the Psych that ships with Ruby 4.0).
Pair = Struct.new(:left, :right)
Point = Data.define(:x, :y)
Pair.new(1, 2).to_yaml # => "--- !ruby/struct:Pair\nleft: 1\nright: 2\n"
Point.new(x: 1, y: 2).to_yaml # => "--- !ruby/data:Point\nx: 1\ny: 2\n"
YAML.unsafe_load(Point.new(x: 1, y: 2).to_yaml) # => #<data Point x=1, y=2>

Two more Psych facilities are worth knowing. YAML.safe_dump (with permitted_classes, permitted_symbols and aliases) refuses to write what safe_load could not read back, a cheap guard against leaking an object graph into a configuration file. YAML.load_stream reads several --- documents, and YAML.parse gives the syntax tree (Psych::Nodes) when you need comments or line numbers, which load discards: Psych does not preserve comments in a load and dump round trip.

CSV

CSV (comma-separated values, informally specified by RFC 4180) is rows of fields. It is the format of spreadsheets and bulk exports, and it is less simple than it looks: fields may be quoted, quoted fields may contain the separator, quotes (doubled) and line breaks, and the separator, the line ending and the encoding all vary between producers. The csv library handles all of that.

CSV is a bundled gem

The csv library was a default gem until Ruby 3.3 and became a bundled gem in Ruby 3.4: it still installs with Ruby and require "csv" works in a plain script, but under Bundler it must be listed in the Gemfile, or the require fails. Ruby 3.3 already printed a warning about the coming change, and Ruby 4.0 behaves as shown below. A gemspec for a gem that uses CSV needs add_dependency "csv" as well. The same move applied to base64, bigdecimal and observer in 3.4, and to logger, benchmark, ostruct, pstore and others in 4.0; see Standard Library Tour and RubyGems and Bundler for the full table. The experiment below uses a Gemfile with no entry for csv, then one with it. With no entry, Bundler hides the gem and Ruby explains the problem:

$ bundle lock --local                       # Gemfile: only the rubygems.org source
$ bundle exec ruby -e 'require "csv"'
-e:1: warning: csv used to be loaded from the standard library, but is not part of the default gems since Ruby 3.4.0.
You can add csv to your Gemfile or gemspec to fix this error.
.../lib/ruby/4.0.0/bundled_gems.rb:60:in 'Kernel.require': cannot load such file -- csv (LoadError)

After adding one line to the Gemfile:

source "https://rubygems.org"

gem "csv"
$ bundle lock --local
$ bundle exec ruby -e 'require "csv"; puts CSV::VERSION'
3.3.5

Reading CSV

CSV.parse(string) returns an array of arrays of strings, and CSV.parse_line parses one line. CSV.read(path) and CSV.foreach(path) do the same for files, foreach streaming rows without loading the file (it returns an Enumerator without a block). With headers: true, the first row names the columns and each row becomes a CSV::Row, an ordered hash-like object (row["title"], row.to_h, row.fields, row.headers) held in a CSV::Table; a table can be indexed by row (table[0]), by column name (table["year"] returns the column) and iterated like an array. CSV.table(path) is shorthand for read with headers, converters: :numeric and symbol headers.

Everything is a String or nil until you convert it. converters: runs a conversion on each field (:integer, :float, :numeric, :date, :date_time, :time, :all, or any lambda taking the field and, optionally, a CSV::FieldInfo); header_converters: normalises header names (:downcase, :symbol, :symbol_raw). Converters are guesses: :numeric turns the ISBN 9780132350884 into an Integer, which drops leading zeros, so convert by column in your own code when the digits are an identifier. A custom converter should return its input unchanged when it does not apply.

The dialect options are col_sep: (for example ";" or "\t"), row_sep: (default autodetected), quote_char:, skip_blanks:, skip_lines: (a regexp or string), strip:, nil_value: and empty_value: (what an empty and a quoted-empty field become), liberal_parsing: (tolerate stray quotes) and encoding:. For a file starting with a byte order mark, as Excel writes, use encoding: "bom|utf-8". Errors are CSV::MalformedCSVError (with line_number), reported for an unclosed quote or illegal quoting, and CSV::InvalidEncodingError when the bytes do not match the encoding (the usual symptom of reading a Latin-1 export as UTF-8). A repeated header keeps both columns in the row but to_h keeps only the first value.

# frozen_string_literal: true

require "csv"

text = <<~CSV
  isbn,title,author,year
  9780132350884,Clean Code,Robert Martin,2008
  9780596516178,"The Ruby Programming Language","Flanagan, David",2008
CSV

CSV.parse(text).first # => ["isbn", "title", "author", "year"]
CSV.parse_line("1,2,3") # => ["1", "2", "3"]

table = CSV.parse(text, headers: true)
table.class # => CSV::Table
table.headers # => ["isbn", "title", "author", "year"]
table.size # => 2
table[1].class # => CSV::Row
table[1]["author"] # => "Flanagan, David"
table["year"] # => ["2008", "2008"]
table[0].to_h # => {"isbn" => "9780132350884", "title" => "Clean Code", "author" => "Robert Martin", "year" => "2008"}

# Converters turn fields into Ruby objects; header converters normalise the keys.
CSV::Converters.keys # => [:integer, :float, :numeric, :date, :date_time, :time, :all]
CSV::HeaderConverters.keys # => [:downcase, :symbol, :symbol_raw]
rows = CSV.parse(text, headers: true, header_converters: :symbol, converters: :numeric).map(&:to_h)
rows.first # => {isbn: 9780132350884, title: "Clean Code", author: "Robert Martin", year: 2008}

# Options for other dialects and untidy input.
CSV.parse("a;b\n1;2", col_sep: ";", headers: true).map(&:to_h) # => [{"a" => "1", "b" => "2"}]
CSV.parse(" a , b \n", strip: true) # => [["a", "b"]]
CSV.parse("a,b\n\n1,2\n", skip_blanks: true) # => [["a", "b"], ["1", "2"]]
CSV.parse("a,b\n#note\n1,2\n", skip_lines: /\A#/) # => [["a", "b"], ["1", "2"]]
CSV.parse("a,b\n1,\n", headers: true, nil_value: "-").first.to_h # => {"a" => "1", "b" => "-"}

# Errors.
begin
  CSV.parse("a,\"b\nc")
rescue CSV::MalformedCSVError => e
  [e.message, e.line_number] # => ["Unclosed quoted field in line 1.", 1]
end

begin
  CSV.parse("a,b\"c\"\n")
rescue CSV::MalformedCSVError => e
  e.message # => "Illegal quoting in line 1."
end
CSV.parse("a,b\"c\"\n", liberal_parsing: true) # => [["a", "b\"c\""]]

Writing CSV

CSV.generate { |csv| csv << row } builds a string, CSV.generate_line(array) one line, and CSV.open(path, "w") { |csv| ... } writes a file. A row is an array or, when headers are set, a hash or a CSV::Row. write_headers: true with headers: writes the header line first. Quoting is automatic: a field is quoted when it contains the separator, a quote or a line break, quotes are doubled, and nil becomes an empty field while "" becomes a quoted empty one (force_quotes: true quotes everything). Pass col_sep: and row_sep: to match the consumer, and open files for writing in the encoding the consumer expects.

Formula injection. A spreadsheet treats a cell that begins with =, `, `-` or `@` as a formula, so data typed by a user into the catalogue (a title such as `=HYPERLINK(…​)+) can run when somebody opens the export. CSV quoting does not prevent it. Neutralise untrusted text when exporting for a spreadsheet by prefixing the dangerous cell with an apostrophe, as the helper in the example does (the apostrophe is the convention that tells spreadsheet programs the cell is text). Do not apply it to data meant for programs, where it would corrupt the value.

# frozen_string_literal: true

require "csv"
require "tmpdir"

out = CSV.generate do |csv|
  csv << %w[isbn title]
  csv << ["9780132350884", "Say \"hi\", ok"]
  csv << ["two\nlines", nil]
end
out.to_s # => "isbn,title\n9780132350884,\"Say \"\"hi\"\", ok\"\n\"two\nlines\",\n"
CSV.generate_line(["a", nil, "", "b c"]) # => "a,,\"\",b c\n"
CSV.generate(col_sep: ";", force_quotes: true) { |csv| csv << [1, "x"] } # => "\"1\";\"x\"\n"

Dir.mktmpdir do |dir|
  path = File.join(dir, "loans.csv")
  CSV.open(path, "w", write_headers: true, headers: %w[isbn state]) do |csv|
    csv << %w[9780132350884 lent]
    csv << { "isbn" => "9780596516178", "state" => "returned" }
  end
  File.read(path) # => "isbn,state\n9780132350884,lent\n9780596516178,returned\n"

  CSV.foreach(path, headers: true).class # => Enumerator
  CSV.foreach(path, headers: true).map { |row| row["state"] } # => ["lent", "returned"]
  CSV.read(path, headers: true).by_col["isbn"] # => ["9780132350884", "9780596516178"]
  CSV.table(path).map(&:to_h) # => [{isbn: 9780132350884, state: "lent"}, {isbn: 9780596516178, state: "returned"}]
  CSV.read(path, headers: true, encoding: "bom|utf-8").size # => 2
end

# Spreadsheets treat a leading =, +, - or @ as a formula: neutralise untrusted text on export.
def csv_safe(value)
  text = value.to_s
  text.match?(/\A[=+\-@\t\r]/) ? "'#{text}" : text
end
csv_safe("=HYPERLINK(\"http://example.invalid\")") # => "'=HYPERLINK(\"http://example.invalid\")"
csv_safe("Clean Code") # => "Clean Code"

Marshal

Marshal is Ruby’s built-in binary serializer. Marshal.dump(object) returns a binary string, and Marshal.load(string) rebuilds the objects. It handles what JSON and CSV cannot: arbitrary object graphs, instances of your own classes, shared and circular references, symbols, big integers, ranges, times, Set and Data. The format has a version, 4.8, in Marshal::MAJOR_VERSION and Marshal::MINOR_VERSION, and has been stable for many Ruby releases: a stream written by one version is generally readable by later ones, and the reader refuses a format it does not know. The format is documented in the language guide (see the references). It is not a stable interchange format: it depends on the class definitions in the reader’s process, and it can be read only by Ruby.

Dump and load

Marshal.dump(object, io = nil, limit = -1) writes to a string, or to an IO when given one, in which case several dumps can be written one after another and read back in order. Marshal.load(source, proc = nil, freeze: false) reads from a string or an IO. The proc form calls the proc for each object as it is rebuilt and uses its return value in place of the object, which is how the deep-copy or filtering tricks work. freeze: true (Ruby 3.1) returns a deeply frozen graph with deduplicated strings; Marshal.restore is an alias of load. The result of dump has the BINARY encoding, so write it with File.binwrite and read it with File.binread.

The best-known use is the deep copy, Marshal.load(Marshal.dump(object)), which is why RuboCop exempts exactly this form from its Security/MarshalLoad cop. It is short but crude: it only works on graphs of dumpable objects, it loses singleton methods, and it is slow compared with writing dup for your own classes (see Object Lifecycle, Freezing and Copying).

Several kinds of object cannot be dumped at all, and raise TypeError: Proc and Method objects, IO objects, Thread, Mutex, Binding, Dir, a Hash with a default proc, an object with singleton methods, and instances of anonymous classes or modules. Attempting to dump a graph that contains one of them fails as a whole, which is the practical reason to keep caches and handles out of the objects you serialize (or define marshal_dump, below).

# frozen_string_literal: true

require "shelf"

book = Shelf::Book.new(isbn: Shelf::Isbn.new(digits: "9780132350884"), title: "Clean Code",
                       author: Shelf::Author.new(name: "Robert Martin"))
bytes = Marshal.dump(book)
bytes.encoding # => #<Encoding:BINARY (ASCII-8BIT)>
bytes.byteslice(0, 2).bytes # => [4, 8]
[Marshal::MAJOR_VERSION, Marshal::MINOR_VERSION] # => [4, 8]

copy = Marshal.load(bytes)
copy.eql?(book) # => true
copy.equal?(book) # => false
copy.title.frozen? # => false
Marshal.load(bytes, freeze: true).title.frozen? # => true

# Object graphs keep their shape, including sharing.
shared = +"shared"
pair = Marshal.load(Marshal.dump([shared, shared]))
pair[0].equal?(pair[1]) # => true

# The classic deep copy: cheap to write, but it only works for dumpable graphs.
original = { tags: ["a", { note: +"x" }] }
deep = Marshal.load(Marshal.dump(original))
deep[:tags][1][:note] << "y"
original.to_h # => {tags: ["a", {note: "x"}]}
deep.to_h # => {tags: ["a", {note: "xy"}]}

# Several values can be written to one stream, in order.
require "stringio"
io = StringIO.new
Marshal.dump([1], io)
Marshal.dump([2], io)
io.rewind
[Marshal.load(io), Marshal.load(io)] # => [[1], [2]]

# A proc sees every object as it is rebuilt, and may replace it.
Marshal.load(Marshal.dump([1, [2]]), ->(object) { object.is_a?(Integer) ? object * 10 : object }) # => [10, [20]]

# Things that cannot be dumped.
[-> {}, $stdout, Thread::Mutex.new, Hash.new { 1 }, Class.new.new].each do |value|
  Marshal.dump(value)
rescue TypeError => e
  puts e.message.sub(/0x\h+/, "0x...")
end
# no _dump_data is defined for class Proc
# can't dump IO
# no _dump_data is defined for class Thread::Mutex
# can't dump hash with default proc
# can't dump anonymous class #<Class:0x...>

Controlling what is dumped

A class takes over its serialization with marshal_dump (return any dumpable object that holds the state) and marshal_load(state) (the instance is allocated without calling initialize, and receives the state). That is the way to leave a cache or a handle out and rebuild it lazily. The older pair, an instance method _dump(limit) returning a string and a class method _load(string) returning the object, still works and is what some C extensions use. Both protocols are the reason loading is dangerous: they are code that runs because the data asked for the class. Subclasses of core types and classes with initialize_copy-style needs usually do better with marshal_dump than with the default instance-variable dump.

# frozen_string_literal: true

# marshal_dump / marshal_load: choose the state, leave out caches and handles.
class Money
  attr_reader :cents, :cache

  def initialize(cents)
    @cents = cents
    @cache = Object.new
  end

  def marshal_dump = @cents
  def marshal_load(cents) = @cents = cents
end

restored = Marshal.load(Marshal.dump(Money.new(500)))
restored.cents # => 500
restored.cache # => nil
# frozen_string_literal: true

# The older _dump / _load pair works on strings; _load is a class method.
class Level
  attr_reader :value

  def initialize(value) = @value = value
  def _dump(_limit) = value.to_s
  def self._load(text) = new(text.to_i)
end

Marshal.dump(Level.new(7)) # => "\x04\bIu:\nLevel\x067\x06:\x06EF"
Marshal.load(Marshal.dump(Level.new(7))).value # => 7

Why you must never load untrusted data

The Ruby documentation says it plainly: do not use Marshal.load on data from an untrusted source. The loader resolves class names from the stream, allocates instances of any class that is loaded in the process, restores their instance variables without running initialize, and calls marshal_load, _load, and, while rebuilding hashes and sets, hash and eql? on the restored objects. An attacker who controls the bytes chooses which of those methods run and on what; chaining methods that already exist in the application and its gems (so-called gadget chains) has led to remote code execution in many Ruby applications and gems, and there is no allow-list option in Marshal.load to make it safe. The same holds for YAML.unsafe_load, for JSON.unsafe_load with additions, and for any cookie, cache entry, queue message or file that an outsider can write to, because Marshal data in a shared cache is only as trusted as everyone who can write to the cache. The remedies are to use JSON (or YAML.safe_load) for anything that crosses a trust boundary, to sign data with a secret (OpenSSL::HMAC) and verify the signature before loading if Marshal is unavoidable, and to restrict write access to the storage. The examples below are all harmless; the lesson is that load does not validate.

The second danger is that initialize does not run. The Shelf::Isbn value object checks its digits in initialize, so Isbn.new rejects a bad number, but Marshal.load of tampered bytes builds an Isbn holding it, with no error. Invariants enforced in constructors are not invariants of loaded objects. The third is versioning: if the class changed (a member renamed, a class moved or removed), the stream still names the old shape, and loading raises ArgumentError for a class that no longer exists and silently yields objects with missing or stale instance variables for one that does. A Marshal cache therefore needs a version in its key, and a failed load must be treated as a cache miss.

# frozen_string_literal: true

require "shelf"

# Marshal rebuilds objects without calling initialize, so validation is skipped.
good = Marshal.dump(Shelf::Isbn.new(digits: "9780132350884"))
tampered = good.sub("9780132350884", "1234567890123")
Marshal.load(tampered).digits # => "1234567890123"
begin
  Shelf::Isbn.new(digits: "1234567890123")
rescue ArgumentError => e
  e.message # => "invalid ISBN: 1234567890123"
end

# A class that is not defined when loading is an error.
Object.const_set(:Gone, Class.new)
dumped = Marshal.dump(Gone.new)
Object.send(:remove_const, :Gone)
begin
  Marshal.load(dumped)
rescue ArgumentError => e
  e.message # => "undefined class/module Gone"
end

# So is a foreign format version, or truncated data.
begin
  Marshal.load("\x04\x09i\x06")
rescue TypeError => e
  e.message # => "incompatible marshal file format (can't be read)\n\tformat version 4.8 required; 4.9 given"
end

begin
  Marshal.load("")
rescue ArgumentError => e
  e.message # => "marshal data too short"
end

pack and unpack

Array#pack(template) converts an array of values to a binary string, and String#unpack(template) (and unpack1, which returns the first value) does the reverse. They exist for fixed binary layouts: the headers of file formats, network protocols, checksums, and Base64 or hex encodings. The template is a string of directive letters, each optionally followed by a count or * (all remaining) and sometimes a modifier. Whitespace in the template is ignored. The result of pack has the BINARY encoding, except that U gives UTF-8.

Directive Meaning

C c

8-bit unsigned / signed integer.

S s, L l, Q q

16-, 32-, 64-bit unsigned / signed integers in the machine’s byte order. Add _ or ! for the native C size (S!, L!, J for a pointer-sized integer).

n N

16-bit and 32-bit unsigned, big-endian (network order).

v V

16-bit and 32-bit unsigned, little-endian (VAX order).

< >

Modifiers for S, L, Q, J and their signed forms: little-endian (L<) or big-endian (L>) regardless of the machine.

a A Z

A string field of fixed width: a pads with null bytes, A pads with spaces (and strips them when unpacking) and Z is null-terminated (Z* packs a terminating null and unpacks up to the first null).

B b, H h

Bit strings and hex strings, most significant bit/digit first (B, H) or least significant first (b, h).

m

Base64. m adds line breaks every 60 characters; m0 writes none and unpack1("m0") is strict (it raises ArgumentError, invalid base64, on bad input). M is quoted-printable and u is uuencode.

U

A UTF-8 code point. w is a BER-compressed integer.

E e, G g, D d, F f

Floating point: little-endian double E, little-endian single e, big-endian double G, big-endian single g, and native D/d, F/f.

x, X, @

Skip a null byte forward (x), back up a byte (X), or move to an absolute offset (@, for unpack).

Integers wider than the directive are truncated without an error ([2**40].pack("N").unpack1("N") is 0), a negative number unpacked with the unsigned directive wraps around ([-1].pack("l").unpack1("L") is 4294967295), and since Ruby 3.3 an unknown directive raises ArgumentError. Ruby 3.2 only printed a warning (unknown pack directive 'y' in 'y') and went on, which let template typos produce wrong data; unpack and unpack1 take an offset: keyword (Ruby 3.1) to start in the middle of a string. Always check that a length field read from the data is plausible before using it to read more.

# frozen_string_literal: true

# A tiny binary record header: magic "SHLF", a version byte and a big-endian record count.
header = ["SHLF", 1, 300].pack("a4 C n")
header.to_s # => "SHLF\x01\x01,"
header.bytesize # => 7
header.encoding # => #<Encoding:BINARY (ASCII-8BIT)>
header.unpack("a4 C n") # => ["SHLF", 1, 300]
header.unpack1("@5 n") # => 300
header.unpack1("n", offset: 5) # => 300

# Integers: width, signedness and byte order.
[258].pack("n") # => "\x01\x02"
[258].pack("v") # => "\x02\x01"
[258].pack("N") # => "\x00\x00\x01\x02"
[258].pack("L<") # => "\x02\x01\x00\x00"
"\x01\x02\x03\x04".unpack1("N") # => 16909060
"\x01\x02\x03\x04".unpack1("V") # => 67305985
[-1].pack("c").unpack1("C") # => 255
[-1].pack("l").unpack1("L") # => 4294967295
[2**40].pack("N").unpack1("N") # => 0

# Strings: space padded, null padded and null terminated.
["abc"].pack("a5") # => "abc\x00\x00"
["abc"].pack("A5") # => "abc  "
["abc"].pack("Z*") # => "abc\x00"
"abc  ".unpack1("A5") # => "abc"
"abc\x00\x00".unpack1("Z*") # => "abc"
"abc\x00\x00".unpack1("a*") # => "abc\x00\x00"

# Text encodings of binary data.
["hello"].pack("m") # => "aGVsbG8=\n"
["hello"].pack("m0") # => "aGVsbG8="
"aGVsbG8=".unpack1("m") # => "hello"
"abc".unpack1("H*") # => "616263"
["616263"].pack("H*") # => "abc"
"a".unpack1("B*") # => "01100001"
[8364].pack("U") # => "€"
"€".unpack("U*") # => [8364]
"€".unpack("C*") # => [226, 130, 172]
[300].pack("w") # => "\x82,"

# Floats.
[1.5].pack("e") # => "\x00\x00\xC0?"
[1.5].pack("g") # => "?\xC0\x00\x00"
"\x00\x00\xC0?".unpack1("e") # => 1.5

# Errors.
begin
  ["a"].pack("N")
rescue TypeError => e
  e.message # => "no implicit conversion of String into Integer"
end

begin
  [1].pack("y")
rescue ArgumentError => e
  e.message # => "unknown pack directive 'y' in 'y'"
end

begin
  [].pack("N")
rescue ArgumentError => e
  e.message # => "too few arguments"
end

begin
  "aGVsbG8".unpack1("m0")
rescue ArgumentError => e
  e.message # => "invalid base64"
end

Base64 is a bundled gem since Ruby 3.4; [string].pack("m0") and string.unpack1("m0") give the same result with no dependency, which is why a lot of code that used Base64.strict_encode64 now uses them. For URL-safe Base64 (- and _) translate the characters with tr or use Base64.urlsafe_encode64 with the gem declared.

A storage adapter and an importer for the shelf

The pieces combine in the shelf scenario. Shelf::Store::JsonFile has the same duck-typed interface as Shelf::Store::Memory (all and add), so Catalog.new(store:) can use it without any other change; it reads with symbolize_names: true and rebuilds each book through the validating constructors, and writes with pretty_generate so the file is diff-friendly. The CSV half exports the catalogue and imports it again, converting every row through the same to_book, so a malformed ISBN in an imported file is rejected at the boundary rather than stored.

# frozen_string_literal: true

require "csv"
require "json"
require "tmpdir"
require "shelf"

module Shelf
  module Store
    # Duck-typed adapter with the same #all and #add as Store::Memory, persisted as JSON.
    class JsonFile
      def initialize(path) = @path = path

      def all
        return [] unless File.exist?(@path)

        JSON.parse(File.read(@path), symbolize_names: true).map { |row| self.class.to_book(row) }
      end

      def add(book)
        write(all << book)
        self
      end

      def self.to_book(row)
        Book.new(isbn: Isbn.new(digits: row.fetch(:isbn)), title: row.fetch(:title),
                 author: Author.new(name: row.fetch(:author)))
      end

      private

      def write(books)
        rows = books.map { |b| { isbn: b.isbn.to_s, title: b.title, author: b.author.name } }
        File.write(@path, JSON.pretty_generate(rows))
      end
    end
  end
end

def book(isbn, title, author)
  Shelf::Book.new(isbn: Shelf::Isbn.new(digits: isbn), title:, author: Shelf::Author.new(name: author))
end

Dir.mktmpdir do |dir|
  store = Shelf::Store::JsonFile.new(File.join(dir, "books.json"))
  catalog = Shelf::Catalog.new(store:)
  catalog.add(book("9780132350884", "Clean Code", "Robert Martin"))
  catalog.add(book("9780596516178", "The Ruby Programming Language", "David Flanagan"))

  puts File.read(File.join(dir, "books.json"))
  # [
  #   {
  #     "isbn": "9780132350884",
  #     "title": "Clean Code",
  #     "author": "Robert Martin"
  #   },
  #   {
  #     "isbn": "9780596516178",
  #     "title": "The Ruby Programming Language",
  #     "author": "David Flanagan"
  #   }
  # ]

  # A second catalog over the same file sees the same books.
  Shelf::Catalog.new(store: Shelf::Store::JsonFile.new(File.join(dir, "books.json"))).map(&:title)
  # => ["Clean Code", "The Ruby Programming Language"]

  # Export to CSV and import again.
  csv_path = File.join(dir, "books.csv")
  CSV.open(csv_path, "w", write_headers: true, headers: %w[isbn title author]) do |csv|
    catalog.each { |b| csv << [b.isbn.to_s, b.title, b.author.name] }
  end
  imported = CSV.foreach(csv_path, headers: true, header_converters: :symbol).map do |row|
    Shelf::Store::JsonFile.to_book(row.to_h)
  end
  imported.map(&:to_s)
  # => ["Clean Code by Robert Martin", "The Ruby Programming Language by David Flanagan"]
end

This adapter rewrites the whole file on every add, which is fine for a few thousand books and wrong for more; a crash in the middle of File.write leaves a truncated file. The safe pattern is to write a temporary file in the same directory and File.rename it over the target, as shown in IO, Files and Directories. For concurrent writers the store also needs a lock (File#flock), and past a point a database is the right store (a SQLite adapter, for instance, would use the sqlite3 gem).

Choosing a format

Situation Use

Data crosses a trust boundary or another language reads it

JSON (JSON.parse with symbolize_names: true, then validate and convert to your own objects).

A person edits the file and wants comments

YAML, loaded with YAML.safe_load and permitted_classes; quote text that looks like a number or a boolean.

Rows for a spreadsheet or bulk import

CSV, with headers and explicit converters; neutralise formulas when users supply the text.

A cache or message between processes of the same Ruby application, written and read only by you

Marshal, with a version in the key and every failed load treated as a miss; otherwise prefer JSON.

A binary file or network layout with fixed fields

pack and unpack with an explicit byte order (<, >, n, N).

Deep copy of a plain graph of arrays, hashes and strings

Marshal.load(Marshal.dump(x)) is acceptable; write dup and initialize_copy for your own classes.

Very large or streaming data

JSON Lines or CSV read line by line; a database or a columnar format (through a gem) beyond that.

Pitfalls

  • to_json on an object with no to_json of its own is to_s.to_json; define to_h and to_json(*), or use strict: true to catch it.

  • Keys: JSON turns every key into a string, so symbols and strings collapse and integer keys do not survive a round trip; parse with symbolize_names: true if you want symbols.

  • JSON.load, YAML.unsafe_load, Marshal.load and JSON.unsafe_load accept more than data; use JSON.parse and YAML.safe_load.

  • YAML.load refuses Date, Time and custom classes since Psych 4; list them in permitted_classes or store dates as strings.

  • YAML 1.1 reads no, NO, off, 010 and 1:30 as non-strings; quote them, and check the types after loading.

  • A repeated key in a YAML mapping silently keeps the last value, and in JSON json 2.18 warns about it.

  • CSV gives you strings; numeric converters destroy identifiers with leading zeros.

  • Without a Gemfile entry, require "csv" fails under Bundler on Ruby 3.4 and later.

  • Marshal.load skips initialize, so validation in constructors is bypassed; do not use it to rebuild objects from outside input.

  • Marshal.dump raises TypeError for procs, IO objects, mutexes, hashes with default procs and singletons; define marshal_dump.

  • Marshal output is binary: write it with File.binwrite.

  • pack truncates silently when a value does not fit the directive, and unpack returns nil entries for missing data instead of raising.

Where older material is outdated

Older claim Why it is misleading Ruby 4.0

YAML.load(untrusted_text) is the normal way to read YAML

True for Psych 3 and Ruby 3.0 and before, where it could instantiate any class. Psych 4.0 (Ruby 3.1) changed load to a restricted loader.

YAML.load is safe-by-default and refuses classes other than Symbol unless permitted; use safe_load with permitted_classes, and unsafe_load only for trusted data.

Marshal.load(Marshal.dump(obj)) as a convenient way to copy

Right for plain data, but treated as general advice it leads to loading Marshal from files and caches.

Fine for a deep copy of your own data; never for bytes from outside. RuboCop’s Security/MarshalLoad allows only that form.

CSV is part of the standard library

It was a default gem until Ruby 3.3.

A bundled gem since 3.4: add gem "csv" to the Gemfile or gemspec.

OpenStruct as the way to turn a parsed JSON object into an object with methods (JSON.parse(text, object_class: OpenStruct))

OpenStruct is slow, hides typos and is a bundled gem in Ruby 4.0, so it needs a Gemfile entry.

Parse into a hash with symbolize_names: true, or build a Data / Struct value object and use pattern matching.

JSON.load is the counterpart of JSON.dump

load was permissive and could create objects through json_class.

JSON.parse / JSON.generate for data; JSON.unsafe_load makes the dangerous behaviour explicit.

Printed hashes look like {:name=>"Ico"}

That is the Ruby 3.3 and earlier inspect.

Ruby 3.4 prints {name: "Ico"} and {"name" => "Ico"}, as the examples here show.

DateTime for timestamps in serialized data

DateTime is deprecated.

Time with iso8601 (after require "time").

pack with an unknown directive is skipped

True before 3.3, where it only warned.

ArgumentError from Ruby 3.3.

Version notes

Ruby Change

3.1

Psych 4.0: YAML.load is safe by default and YAML.unsafe_load is introduced. Marshal.load(freeze: true). String#unpack and unpack1 take offset:.

3.3

Array#pack and String#unpack raise ArgumentError for unknown directives. The csv default gem warns that it will become a bundled gem.

3.4

csv, base64 and bigdecimal become bundled gems. json 2.9.1 accepts allow_trailing_comma:. Hash#inspect prints {name: "Ico"}.

4.0

json 2.18.0: JSON::Coder, a warning for duplicate keys and for the implicit create_additions of JSON.load. Psych 5.3.1 dumps and loads Data (!ruby/data:). csv 3.3.5 is a bundled gem.