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 with YAML.load — always use YAML.safe_load with minimal permitted_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 #

AspectYAMLJSON
ReadabilityVery high — no brackets, minimal quotesMedium — lots of punctuation
Manual writingEasier for humans to editEasier to get wrong (forgotten quotes)
CommentsSupported (#)Not supported
Data typesRicher (Date, Time, etc.)Limited (string, number, bool, null)
SecurityMust be careful (object injection)Safer by default
Parsing performanceSlowerFaster
Tooling supportLessUniversal
Best forConfig files, fixtures, i18nAPI 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_load not loadYAML.load can execute arbitrary Ruby code from untrusted input; safe_load only 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&anchor and *alias with <<: for merging avoids configuration duplication across environments.
  • ERB in YAML for dynamic values — use <%= ENV["VAR"] %> for per-environment configuration without hardcoding.
  • permitted_classes for non-standard types — if you need Date or Time from YAML, add them to permitted_classes: [Date, Time] in safe_load.
  • Don’t commit credentials in YAML — use environment variables or encrypted Rails credentials, not plaintext in files committed to Git.
  • Psych::SyntaxError for 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.

← Previous: JSON   Next: MySQL →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact