YAML #
YAML (YAML Ain’t Markup Language) is a data serialization format designed to be human-readable. Compared to JSON, which is stricter with brackets and quotes, YAML uses indentation and minimal punctuation — the result is much cleaner for configuration files that are frequently edited by hand. Ruby uses YAML extensively: config/database.yml, config/locales/*.yml, and config/credentials.yml.enc in Rails are all YAML. The library Ruby uses is Psych — a YAML parser written by Aaron Patterson and built in since Ruby 1.9.3. This article covers all aspects of YAML in Ruby, from basic syntax to the security concerns that are often overlooked.
YAML Syntax — A Quick Guide #
YAML uses indentation (spaces, not tabs) to define hierarchical structure:
# Comments start with #
# Mapping (like a Hash in Ruby)
name: Rina Wijaya
age: 28
city: Bandung
active: true
balance: 1500000.50
# Null value
secondary_email: null # or: ~
# Multi-line string with | (literal, preserves newlines)
bio: |
A Ruby developer
who loves open source
and black coffee.
# Multi-line string with > (folded, newlines become spaces)
description: >
This is a long text
that will be joined into
one line with spaces.
# Sequence (like an Array in Ruby)
programming_languages:
- Ruby
- Python
- Go
- Rust
# Inline sequence
hobbies: [reading, coding, hiking]
# Inline mapping
coordinates: {lat: -6.9147, lng: 107.6098}
# Nested mapping
address:
street: Jl. Sudirman No. 42
district: Dago
city: Bandung
postal_code: "40135" # quoted to force a String type
# Array of mappings
work_history:
- company: Startup ABC
position: Junior Developer
years: 2019-2021
- company: Tech Corp
position: Senior Developer
years: 2021-present
Automatic Data Types in YAML #
YAML does automatic type coercion — this can be surprising:
# Integer
count: 42
negative: -10
octal: 0o17 # => 15
hex: 0xFF # => 255
# Float
pi: 3.14159
e_notation: 1.5e3 # => 1500.0
infinite: .inf
not_a_number: .nan
# Boolean — CAREFUL! This differs between YAML 1.1 vs 1.2
true_value: true
false_value: false
# In YAML 1.1 (old Psych default): yes, no, on, off are also booleans!
# In YAML 1.2 (Psych 4+): only true and false
# Dates and times (automatically become Ruby Time/Date objects!)
birth_date: 1990-08-17
timestamp: 2024-08-15 14:30:00 +07:00
# Strings that look like other types — use quotes
postal_code: "40135" # without quotes → Integer
version: "1.0" # without quotes → Float
active: "true" # without quotes → Boolean
Loading YAML — Psych #
Psych is Ruby’s standard YAML library. It’s accessed through the YAML module:
require 'yaml'
# YAML.load — parse a YAML string into Ruby objects
yaml_str = <<~YAML
name: Budi
age: 30
hobbies:
- reading
- coding
YAML
data = YAML.load(yaml_str)
puts data.class # => Hash
puts data["name"] # => "Budi"
puts data["hobbies"] # => ["reading", "coding"]
# YAML.load_file — parse directly from a file
config = YAML.load_file("config/database.yml")
puts config["development"]["host"]
# With permitted_classes for allowed types (Ruby 3.1+)
data = YAML.load(yaml_str, permitted_classes: [Symbol, Date, Time])
safe_load vs load — Critical Security #
# ANTI-PATTERN: YAML.load with untrusted input
# DANGEROUS! Can execute arbitrary Ruby code (object deserialization)
data = YAML.load(user_input) # CVE-2013-0156 — famous Rails vulnerability
# CORRECT: YAML.safe_load — only parses basic types
# Won't deserialize custom Ruby objects
data = YAML.safe_load(user_input)
# safe_load allows by default:
# String, Integer, Float, Array, Hash, true, false, nil
# Allow additional types explicitly if needed
data = YAML.safe_load(
yaml_str,
permitted_classes: [Symbol, Date, Time, BigDecimal]
)
# Allow Symbol keys (useful for Rails fixtures)
data = YAML.safe_load(yaml_str, symbolize_names: true)
YAML security rules:
✓ Use safe_load for input from users or external sources
✓ Use safe_load for config files that users can edit
✓ Allow only the classes truly needed via permitted_classes
✗ Don't use load for input you don't fully control
✗ Don't deserialize custom objects from untrusted sources
YAML ↔ Ruby Type Mapping #
YAML Ruby
────────────────────────────────────────────────
mapping {} → Hash (keys: String by default)
sequence [] → Array
string → String
integer → Integer
float → Float
true / false → TrueClass / FalseClass
null / ~ → NilClass
2024-08-15 → Date (with safe_load + permitted_classes)
2024-08-15 14:30:00 → Time (with safe_load + permitted_classes)
!!ruby/object:Class → Ruby class instance (ONLY with load, not safe_load)
Creating YAML — dump and dump_file #
YAML.dump converts Ruby objects into a YAML string:
require 'yaml'
# Simple hash
data = { name: "Citra", age: 25, city: "Surabaya" }
puts YAML.dump(data)
# => ---
# :name: Citra
# :age: 25
# :city: Surabaya
# Note: Symbol keys become :name, not name
# If you want String keys, convert first
data_str_keys = { "name" => "Citra", "age" => 25 }
puts YAML.dump(data_str_keys)
# => ---
# name: Citra
# age: 25
# Array
arr = ["Ruby", "Python", "Go"]
puts YAML.dump(arr)
# => ---
# - Ruby
# - Python
# - Go
# Nested structure
config = {
"development" => {
"database" => "app_development",
"host" => "localhost",
"port" => 5432
},
"production" => {
"database" => "app_production",
"host" => "db.example.com",
"port" => 5432
}
}
puts YAML.dump(config)
# Save to a file
File.write("config/app.yml", YAML.dump(config))
# Or use Psych directly for more control
File.open("config/app.yml", "w") do |f|
f.write(YAML.dump(config))
end
Psych.dump with Indentation Options #
# Control output indentation
yaml_output = Psych.dump(data, indentation: 4) # 4 spaces
puts yaml_output
# The "---" header can be disabled
yaml_output = Psych.dump(data, header: false)
# Full set of Psych.dump options
Psych.dump(
data,
indentation: 2, # indentation spaces (default: 2)
width: 80, # maximum line width for folding
header: true, # include "---" at the start
canonical: false, # canonical format (more verbose)
line_width: 80
)
Anchors and Aliases — Avoiding Duplication #
YAML supports anchors (&) and aliases (*) to define a value once and reference it many times:
# Define an anchor
default_connection: &default_db
adapter: postgresql
encoding: unicode
pool: 5
timeout: 5000
# Alias — reuse the same values
development:
<<: *default_db # <<: merges all keys from the anchor
database: app_development
host: localhost
test:
<<: *default_db
database: app_test
host: localhost
production:
<<: *default_db
database: app_production
host: <%= ENV['DB_HOST'] %>
pool: <%= ENV['DB_POOL'] || 10 %>
This is the pattern Rails uses in config/database.yml. <<: is the merge key — all key-value pairs from the anchor are merged into the current mapping, but can be overridden:
require 'yaml'
yaml_str = <<~YAML
default: &default
timeout: 30
retries: 3
debug: false
production:
<<: *default
timeout: 60 # overrides the default value
host: prod.db.com
YAML
config = YAML.safe_load(yaml_str)
puts config["production"]["timeout"] # => 60 (override)
puts config["production"]["retries"] # => 3 (from the anchor)
puts config["production"]["host"] # => "prod.db.com"
Multi-Document YAML #
A single YAML file can contain several documents separated by ---:
# A file with several documents
---
id: 1
name: Laptop
price: 15000000
---
id: 2
name: Mouse
price: 350000
---
id: 3
name: Keyboard
price: 450000
require 'yaml'
# Read all documents from a multi-document file
yaml_content = File.read("products.yml")
# Psych.load_stream — parse all documents
documents = []
Psych.load_stream(yaml_content) { |doc| documents << doc }
puts documents.length # => 3
puts documents.first["name"] # => "Laptop"
# Or with load_stream without a block
documents = Psych.load_stream(yaml_content)
puts documents.inspect
# Create a multi-document file
products = [
{ id: 1, name: "Laptop", price: 15_000_000 },
{ id: 2, name: "Mouse", price: 350_000 },
{ id: 3, name: "Keyboard", price: 450_000 }
]
# Combine with "---" as the separator
yaml_output = products.map { |p| YAML.dump(p) }.join
File.write("products.yml", yaml_output)
ERB in YAML — Dynamic Configuration #
Rails and many Ruby gems support ERB (Embedded Ruby) inside YAML files. This allows dynamic values from environment variables or Ruby calculations:
# config/database.yml — ERB inside YAML
default: &default
adapter: postgresql
encoding: unicode
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
username: <%= ENV["DB_USERNAME"] || "postgres" %>
password: <%= ENV["DB_PASSWORD"] %>
host: <%= ENV["DB_HOST"] || "localhost" %>
development:
<<: *default
database: <%= "#{Rails.application.class.module_parent_name.underscore}_development" %>
test:
<<: *default
database: <%= "#{Rails.application.class.module_parent_name.underscore}_test" %>
production:
<<: *default
database: <%= ENV["DB_NAME"] %>
host: <%= ENV["DB_HOST"] %>
port: <%= ENV["DB_PORT"] || 5432 %>
# Parsing YAML with ERB manually
require 'yaml'
require 'erb'
def read_yaml_with_erb(path)
template = ERB.new(File.read(path))
yaml_str = template.result(binding)
YAML.safe_load(yaml_str)
end
config = read_yaml_with_erb("config/app.yml")
Rails Configuration with YAML #
YAML is the backbone of configuration in Rails. Here are the common patterns:
Localization (i18n) #
# config/locales/id.yml
id:
hello: "Halo"
activerecord:
models:
user: "Pengguna"
produk: "Produk"
attributes:
user:
nama: "Nama Lengkap"
email: "Alamat Email"
umur: "Usia"
produk:
nama: "Nama Produk"
harga: "Harga"
stok: "Stok"
errors:
models:
user:
attributes:
email:
blank: "tidak boleh kosong"
invalid: "format tidak valid"
nama:
too_short: "minimal %{count} karakter"
Custom Application Configuration #
# config/app_config.yml
defaults: &defaults
app_name: "My Online Store"
version: "2.1.0"
max_upload_size: 10485760 # 10 MB in bytes
allowed_image_formats:
- jpg
- jpeg
- png
- webp
admin_email: "[email protected]"
features:
live_chat: true
payment_gateway: midtrans
product_reviews: true
development:
<<: *defaults
debug_mode: true
admin_email: "dev@localhost"
features:
live_chat: false # disabled in development
test:
<<: *defaults
debug_mode: false
production:
<<: *defaults
debug_mode: false
max_upload_size: 5242880 # stricter in production: 5 MB
# Load configuration with ERB and environment detection
class AppConfig
def self.load
path = Rails.root.join("config", "app_config.yml")
raw = ERB.new(File.read(path)).result
all = YAML.safe_load(raw, permitted_classes: [Symbol])
# Take the configuration for the current environment
defaults = all["defaults"] || {}
env_config = all[Rails.env] || {}
defaults.merge(env_config)
end
CONFIG = load.freeze
def self.[](key)
CONFIG[key.to_s]
end
end
# Access
puts AppConfig["app_name"] # => "My Online Store"
puts AppConfig["max_upload_size"] # => 5242880 (in production)
Rails Fixtures — YAML for Test Data #
Rails uses YAML for fixtures — test data loaded into the database before tests:
# test/fixtures/users.yml
rina:
name: Rina Wijaya
email: [email protected]
role: user
active: true
created_at: <%= Time.now.utc %>
admin:
name: Main Admin
email: [email protected]
role: admin
active: true
created_at: <%= Time.now.utc %>
inactive_user:
name: Old User
email: [email protected]
role: user
active: false
created_at: 2020-01-01 00:00:00
# Usage in MiniTest
class UserTest < ActiveSupport::TestCase
test "an active user can log in" do
user = users(:rina) # get a fixture by its label
assert user.active?
end
test "an admin has full access" do
admin = users(:admin)
assert admin.role == "admin"
end
end
Custom Object Serialization #
YAML can store and reload custom Ruby objects — but this must be done carefully for security reasons:
require 'yaml'
class Point
attr_reader :x, :y
def initialize(x, y)
@x = x
@y = y
end
def to_s
"(#{@x}, #{@y})"
end
# Customize the YAML representation (optional)
def encode_with(coder)
coder["x"] = @x
coder["y"] = @y
end
def init_with(coder)
@x = coder["x"]
@y = coder["y"]
end
end
point = Point.new(3, 4)
# Serialize to YAML
yaml_str = YAML.dump(point)
puts yaml_str
# => --- !ruby/object:Point
# x: 3
# y: 4
# Deserialize — ONLY use YAML.load, NOT safe_load
point_back = YAML.load(yaml_str, permitted_classes: [Point])
puts point_back # => (3, 4)
puts point_back.x # => 3
Serializing custom Ruby objects to YAML is only safe if you fully control the YAML files being loaded. Never load YAML from user input or the network withYAML.load— always useYAML.safe_loadwith minimalpermitted_classes.
Reading and Writing YAML Files #
require 'yaml'
# Read a YAML file
def read_yaml(path)
YAML.safe_load_file(path, symbolize_names: true)
rescue Errno::ENOENT
raise "File not found: #{path}"
rescue Psych::SyntaxError => e
raise "Invalid YAML syntax in #{path}: #{e.message}"
end
config = read_yaml("config/settings.yml")
puts config[:database][:host]
# Write to a YAML file
def write_yaml(path, data)
File.write(path, YAML.dump(data))
rescue IOError => e
raise "Failed to write file: #{e.message}"
end
write_yaml("output/result.yml", { status: "success", total: 42 })
# Update an existing YAML file
def update_yaml(path, &block)
data = read_yaml(path)
new_data = block.call(data)
write_yaml(path, new_data.transform_keys(&:to_s))
end
update_yaml("config/settings.yml") do |config|
config.merge(version: "2.0", updated: Time.now.to_s)
end
# Read YAML with ERB
def read_yaml_erb(path, binding_obj = binding)
template = ERB.new(File.read(path))
yaml_str = template.result(binding_obj)
YAML.safe_load(yaml_str, permitted_classes: [Symbol, Date, Time])
end
YAML Error Handling #
# YAML syntax errors
begin
YAML.safe_load("name: Rina\n bad_indent")
rescue Psych::SyntaxError => e
puts "Invalid syntax: #{e.message}"
puts "Line: #{e.line}, Column: #{e.column}"
end
# Parsing errors due to disallowed classes
begin
YAML.safe_load("--- !ruby/object:Point\nx: 3")
rescue Psych::DisallowedClass => e
puts "Class not allowed: #{e.message}"
end
# Validate before loading
def yaml_valid?(str)
YAML.safe_load(str)
true
rescue Psych::SyntaxError
false
end
puts yaml_valid?("name: Rina") # => true
puts yaml_valid?("name: : oops") # => false
YAML vs JSON Comparison #
| Aspect | YAML | JSON |
|---|---|---|
| Readability | Very high — no brackets, minimal quotes | Medium — lots of punctuation |
| Manual writing | Easier for humans to edit | Easier to get wrong (forgotten quotes) |
| Comments | Supported (#) | Not supported |
| Data types | Richer (Date, Time, etc.) | Limited (string, number, bool, null) |
| Security | Must be careful (object injection) | Safer by default |
| Parsing performance | Slower | Faster |
| Tooling support | Less | Universal |
| Best for | Config files, fixtures, i18n | API responses, data exchange |
# YAML ↔ JSON conversion
require 'yaml'
require 'json'
# YAML → JSON
yaml_str = File.read("config.yml")
data = YAML.safe_load(yaml_str)
json_str = JSON.pretty_generate(data)
File.write("config.json", json_str)
# JSON → YAML
json_str = File.read("data.json")
data = JSON.parse(json_str)
yaml_str = YAML.dump(data)
File.write("data.yml", yaml_str)
YAML Anti-Patterns to Avoid #
# ANTI-PATTERN 1: Ambiguous unquoted values
postal_code: 40135 # → Integer! not String
version: 1.0 # → Float! not String
active: yes # → true in YAML 1.1 (old Psych)
# CORRECT: quote values that must be Strings
postal_code: "40135"
version: "1.0"
active: "yes" # or use explicit boolean true/false
# ANTI-PATTERN 2: Tabs for indentation
# YAML doesn't allow tabs — must be spaces!
database:
host: localhost # ← tab — SyntaxError!
# CORRECT: spaces
database:
host: localhost # ← 2 spaces
# ANTI-PATTERN 3: Duplicate keys
server:
host: localhost
port: 5432
host: db.example.com # duplicate! the first value is ignored
# CORRECT: every key is unique
server:
host: db.example.com
port: 5432
# ANTI-PATTERN 4: YAML.load with untrusted input
config = YAML.load(params[:config]) # ← dangerous! can execute code
# CORRECT: safe_load
config = YAML.safe_load(params[:config])
# ANTI-PATTERN 5: Storing credentials directly in committed YAML
# config/database.yml
production:
password: "p@ssw0rd123secret" # ← don't commit this!
# CORRECT: use environment variables or Rails credentials
production:
password: <%= ENV["DB_PASSWORD"] %>
Summary #
- Use
safe_loadnotload—YAML.loadcan execute arbitrary Ruby code from untrusted input;safe_loadonly allows basic types and is safe for almost all cases.- Quote ambiguous values —
"40135","1.0","true"to make sure values are Strings, not unexpected Integers/Floats/Booleans.- Spaces, not tabs — YAML only allows spaces for indentation; tabs cause a confusing
Psych::SyntaxError.- Anchors and aliases for DRY —
&anchorand*aliaswith<<:for merging avoids configuration duplication across environments.- ERB in YAML for dynamic values — use
<%= ENV["VAR"] %>for per-environment configuration without hardcoding.permitted_classesfor non-standard types — if you need Date or Time from YAML, add them topermitted_classes: [Date, Time]insafe_load.- Don’t commit credentials in YAML — use environment variables or encrypted Rails credentials, not plaintext in files committed to Git.
Psych::SyntaxErrorfor malformed format detection — handle this exception when reading YAML from external sources or user-editable files.- YAML for configuration, JSON for APIs — YAML is better for files frequently edited by humans (config, i18n, fixtures); JSON is better for data exchange between systems.
- Multi-documents with
Psych.load_stream— a single YAML file can contain multiple documents separated by---; useful for seed data or batch imports.