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 |
|
Interchange between programs and languages; web APIs; configuration without comments. |
None for |
YAML |
|
Human-edited configuration, fixtures, small documents with comments and anchors. |
None for |
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. |
|
Core ( |
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
Source: Ruby 4.0 documentation: JSON.parse and JSON::ParserError
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\"}"
Source: Ruby 4.0 documentation: JSON::Coder and Data
Options, limits and the json_class trap
JSON.parse is deliberately strict. The options below loosen it for specific, known input.
| Option | Effect |
|---|---|
|
Hash keys become symbols. |
|
Every parsed object is frozen. Useful for constants and for data shared between Ractors. |
|
Raises |
|
Accepts the non-standard literals |
|
Accepts |
|
The last value of a repeated key wins. With json 2.18 (Ruby 4.0) a repeated key warns by default, |
|
Non-integer numbers are built with this class instead of |
|
Build objects and arrays with other classes that respond to |
|
Legacy: honour the |
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 |
|---|---|---|
|
Booleans, numbers, strings, |
Any input you did not write: uploads, API bodies, user configuration. |
|
The same as |
Files your own program or team wrote, when you want symbols. |
|
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 |
|---|---|
|
Classes that may appear. |
|
With |
|
Allows anchors and aliases ( |
|
Hash keys become symbols (like JSON). |
|
Freezes the loaded objects. |
|
A name for error messages, so |
|
The value returned for an empty document ( |
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
Source: Ruby 4.0 documentation: Psych.safe_load, Psych.load, Psych.unsafe_load and Psych::DisallowedClass
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\""]]
Source: csv gem documentation: CSV.parse, CSV.foreach and Recipes: parsing
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"
Source: csv gem documentation: CSV.generate, CSV.open and RFC 4180
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
Source: Ruby 4.0 documentation: Marshal
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 |
|---|---|
|
8-bit unsigned / signed integer. |
|
16-, 32-, 64-bit unsigned / signed integers in the machine’s byte order. Add |
|
16-bit and 32-bit unsigned, big-endian (network order). |
|
16-bit and 32-bit unsigned, little-endian (VAX order). |
|
Modifiers for |
|
A string field of fixed width: |
|
Bit strings and hex strings, most significant bit/digit first ( |
|
Base64. |
|
A UTF-8 code point. |
|
Floating point: little-endian double |
|
Skip a null byte forward ( |
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 ( |
A person edits the file and wants comments |
YAML, loaded with |
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 |
|
Deep copy of a plain graph of arrays, hashes and strings |
|
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_jsonon an object with noto_jsonof its own isto_s.to_json; defineto_handto_json(*), or usestrict: trueto 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: trueif you want symbols. -
JSON.load,YAML.unsafe_load,Marshal.loadandJSON.unsafe_loadaccept more than data; useJSON.parseandYAML.safe_load. -
YAML.loadrefusesDate,Timeand custom classes since Psych 4; list them inpermitted_classesor store dates as strings. -
YAML 1.1 reads
no,NO,off,010and1:30as 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.
-
CSVgives 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.loadskipsinitialize, so validation in constructors is bypassed; do not use it to rebuild objects from outside input. -
Marshal.dumpraisesTypeErrorfor procs, IO objects, mutexes, hashes with default procs and singletons; definemarshal_dump. -
Marshaloutput is binary: write it withFile.binwrite. -
packtruncates silently when a value does not fit the directive, andunpackreturnsnilentries for missing data instead of raising.
Where older material is outdated
| Older claim | Why it is misleading | Ruby 4.0 |
|---|---|---|
|
True for Psych 3 and Ruby 3.0 and before, where it could instantiate any class. Psych 4.0 (Ruby 3.1) changed |
|
|
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 |
CSV is part of the standard library |
It was a default gem until Ruby 3.3. |
A bundled gem since 3.4: add |
|
|
Parse into a hash with |
|
|
|
Printed hashes look like |
That is the Ruby 3.3 and earlier |
Ruby 3.4 prints |
|
|
|
|
True before 3.3, where it only warned. |
|
Version notes
| Ruby | Change |
|---|---|
3.1 |
Psych 4.0: |
3.3 |
|
3.4 |
|
4.0 |
json 2.18.0: |
References
-
Ruby 4.0 documentation: JSON, JSON::Coder and the json gem repository
-
Ruby 4.0 documentation: Psych (the
YAMLmodule), the psych repository and YAML 1.1 specification -
csv gem documentation, CSV recipes, the csv repository and RFC 4180
-
Ruby 4.0 documentation: Marshal and Marshal format documentation
-
Ruby 4.0 documentation: Security and Standard library (default and bundled gems)