YAML Is Not "JSON but Prettier"
The common description — "YAML is human-readable, JSON is machine-readable" — obscures the real differences. YAML is an 80+ page specification with implicit type coercion, anchors, tags, merge keys, and multi-document support. JSON is a 1-page specification with no ambiguity.
This distinction matters because YAML's complexity creates type coercion traps, security vulnerabilities, and lossy conversion boundaries that don't exist in JSON.
The Norway Problem: Implicit Type Coercion
YAML's most infamous design flaw: bare values are implicitly coerced into typed values based on pattern matching.
Boolean Hell
In YAML 1.1 (used by most tools until recently), the following are all parsed as boolean true:
# All of these become boolean true in YAML 1.1
country_code: NO # Norway's ISO code → false (!)
answer: yes # → true
enabled: on # → true
flag: TRUE # → true
value: y # → true
The "Norway problem": a country code list where NO (Norway) silently becomes false:
countries:
- DK # string "DK"
- FI # string "FI"
- NO # boolean false (!)
- SE # string "SE"
After parsing: ["DK", "FI", false, "SE"]
Numeric Coercion
version: 1.0 # float 1.0, not string "1.0"
zipcode: 01onal # string (not octal because of 'o')
port: 0755 # octal 493 in YAML 1.1 (!), string in 1.2
time: 12:30 # sexagesimal 750 in YAML 1.1 (!), string in 1.2
YAML 1.1 vs 1.2: The Critical Difference
| Value | YAML 1.1 interpretation | YAML 1.2 interpretation |
|---|---|---|
yes / no |
Boolean | String |
on / off |
Boolean | String |
y / n |
Boolean | String |
0755 |
Octal integer (493) | String |
1:30 |
Sexagesimal (90) | String |
true / false |
Boolean | Boolean |
null / ~ |
Null | Null |
YAML 1.2 fixed the worst coercion traps by limiting booleans to only true/false and integers to only decimal notation. But most YAML libraries still default to YAML 1.1 behavior (including PyYAML, Ruby's Psych, and older versions of js-yaml).
The Fix: Always Quote Ambiguous Values
# Safe: explicitly quoted strings
country_code: "NO"
version: "1.0"
port: "0755"
enabled: "yes"
Security: YAML Deserialization Attacks
YAML's tag system allows specifying arbitrary types, which in many languages means arbitrary code execution.
Python PyYAML: Code Execution via Tags
# DANGEROUS: executes os.system("rm -rf /") when loaded with yaml.load()
!!python/object/apply:os.system ["rm -rf /"]
import yaml
# VULNERABLE: yaml.load() with default Loader processes tags
data = yaml.load(malicious_yaml) # arbitrary code execution!
# SAFE: yaml.safe_load() ignores custom tags
data = yaml.safe_load(yaml_string) # only basic types
Always use safe_load() / SafeLoader. The yaml.load() without explicit Loader is the most common YAML vulnerability pattern.
YAML Bombs (Billion Laughs)
YAML anchors enable exponential expansion:
a: &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol"]
b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a]
c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b]
d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c]
e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d]
Five levels of 9× expansion: 9⁵ = 59,049 strings from a few lines. This can exhaust memory and crash the parser.
Defense: set recursion limits and maximum expansion size in your parser configuration.
JSON Is Immune
JSON has no tag system, no anchors, and no mechanism for code execution during parsing. This is a security advantage of JSON's simplicity.
What Is Lost in Each Conversion Direction
YAML → JSON (Information Loss)
| YAML feature | Behavior after conversion |
|---|---|
Comments (#) |
Lost — no representation in JSON |
| Anchors & aliases | Expanded into duplicate data (increases size) |
Merge keys (<<) |
Expanded into flat objects |
Multi-document (---) |
Lost — JSON has no multi-document concept |
Custom tags (!!type) |
Lost or causes error |
Multi-line scalars (|, >) |
Converted to single string with \n |
Document markers (---, ...) |
Lost |
| Key ordering | Implementation-dependent — JSON spec doesn't guarantee order |
JSON → YAML (No Loss, but Choices)
JSON → YAML is lossless (YAML is a superset of JSON). But the conversion involves choices:
- Indentation style (2 vs 4 spaces)
- Flow vs block style for arrays and objects
- Quote strategy (quote everything, or only when needed)
- Whether to use anchors for repeated sub-trees
Parsing Library Security Postures
| Library | Language | Default behavior | Safe mode | YAML version |
|---|---|---|---|---|
| PyYAML | Python | Unsafe (tags executed) | safe_load() |
1.1 |
| ruamel.yaml | Python | Roundtrip-safe | Configurable | 1.2 |
| strictyaml | Python | Safe (no tags, no implicit types) | Always safe | Subset |
| js-yaml | JavaScript | Safe (tags ignored by default) | Default | 1.2 (since v4) |
| go-yaml | Go | Safe (no arbitrary execution) | Default | 1.2 (v3) |
| SnakeYAML | Java | Unsafe (tags processed) | SafeConstructor |
1.1 |
| Jackson YAML | Java | Configurable | Via ObjectMapper | 1.1 |
Recommendation
- Python: Use
strictyamlfor config files (eliminates all implicit coercion),ruamel.yamlfor roundtrip editing, neveryaml.load()withoutSafeLoader - JavaScript:
js-yamlv4+ is safe by default - Go:
gopkg.in/yaml.v3follows YAML 1.2, safe by default - Java: Never use
new Yaml().load()on untrusted input — useSafeConstructor
When TOML Is the Better Choice
TOML (Tom's Obvious Minimal Language) was designed specifically for configuration files, avoiding both YAML's complexity and JSON's lack of comments:
# TOML: explicit types, no coercion, comments supported
[server]
host = "localhost"
port = 5432
enabled = true
[database]
name = "myapp"
pool_size = 20
[[routes]]
path = "/api/users"
method = "GET"
[[routes]]
path = "/api/orders"
method = "POST"
YAML vs JSON vs TOML Decision Matrix
| Criterion | YAML | JSON | TOML |
|---|---|---|---|
| Human readability | Good (if simple) | Moderate | Good |
| Machine parsing speed | Slow | Fast | Moderate |
| Comments | Yes | No | Yes |
| Type safety | Poor (implicit coercion) | Good (explicit) | Good (explicit) |
| Security risk | High (tags, bombs) | Minimal | Minimal |
| Spec complexity | 80+ pages | 1 page | ~20 pages |
| Nested structures | Unlimited depth | Unlimited depth | Awkward past 3 levels |
| Industry adoption | Kubernetes, CI/CD, Ansible | APIs, package.json | Rust (Cargo.toml), Python (pyproject.toml) |
Use YAML When
- The ecosystem requires it (Kubernetes, GitHub Actions, Ansible)
- You need multi-document files
- You need anchors/aliases for DRY configuration
Use JSON When
- Exchanging data between services (APIs)
- The consumer is JavaScript/browser-based
- You need schema validation (JSON Schema is mature)
- Security is paramount (no code execution risk)
Use TOML When
- Writing application configuration
- You want comments without YAML's type coercion risks
- Nesting is shallow (≤3 levels)
- The ecosystem supports it (Rust, Python packaging, Hugo)
Correct Conversion Code
Python (Safe)
import json
from ruamel.yaml import YAML
yaml = YAML()
yaml.preserve_quotes = True
# YAML → JSON (safe, YAML 1.2)
with open('config.yaml') as f:
data = yaml.load(f)
json_str = json.dumps(data, indent=2, ensure_ascii=False)
# JSON → YAML
with open('data.json') as f:
data = json.load(f)
with open('output.yaml', 'w') as f:
yaml.dump(data, f)
JavaScript / Node.js
import { load, dump } from 'js-yaml';
import { readFileSync, writeFileSync } from 'fs';
// YAML → JSON
const yamlContent = readFileSync('config.yaml', 'utf8');
const data = load(yamlContent); // js-yaml v4: safe by default
const jsonStr = JSON.stringify(data, null, 2);
// JSON → YAML
const jsonContent = readFileSync('data.json', 'utf8');
const parsed = JSON.parse(jsonContent);
const yamlStr = dump(parsed, { indent: 2, lineWidth: 120 });
Go
package main
import (
"encoding/json"
"os"
"gopkg.in/yaml.v3"
)
func yamlToJSON(yamlBytes []byte) ([]byte, error) {
var data interface{}
if err := yaml.Unmarshal(yamlBytes, &data); err != nil {
return nil, err
}
// yaml.v3 uses map[string]interface{}, compatible with json.Marshal
return json.MarshalIndent(data, "", " ")
}
CLI Tools
# yq: YAML swiss-army knife
yq -o=json config.yaml > config.json
yq -P config.json > config.yaml
# Python one-liner
python -c "import sys,yaml,json; print(json.dumps(yaml.safe_load(sys.stdin),indent=2))" < config.yaml
Common Pitfalls in Production
1. Indentation Errors Are Silent
YAML indentation determines structure. A single-space error changes meaning:
# Intended: nested under server
server:
host: localhost
port: 5432
# Bug: port is a sibling of server (wrong indentation)
server:
host: localhost
port: 5432
2. Tabs vs Spaces
YAML forbids tabs for indentation. A tab character causes a parse error, but many editors display tabs and spaces identically.
3. Unquoted Strings That Look Like Other Types
version: 3.10 # float 3.1 (trailing zero dropped!)
version: "3.10" # string "3.10" (correct)
4. Multiline String Trailing Newlines
# | preserves a trailing newline
content: |
hello
# |- strips the trailing newline
content: |-
hello
The difference between | and |- matters for templates, scripts, and any value where a trailing newline changes behavior.
Summary
YAML and JSON are not interchangeable formats with different syntax. They have fundamentally different security models, type systems, and information capacity. Converting between them is lossy in the YAML→JSON direction (comments, anchors, tags, multi-document) and requires choices in the JSON→YAML direction (style, quoting, indentation).
Key principles:
- Always use safe parsing functions (
safe_load,SafeConstructor) for untrusted YAML - Quote values that could be implicitly coerced (
"NO","3.10","yes") - Prefer YAML 1.2 libraries that eliminate boolean coercion traps
- For new configuration files where ecosystem doesn't mandate YAML, consider TOML
- Treat YAML→JSON conversion as lossy and validate the output