Skip to content

YAML Aide-mémoire

Human-friendly data serialization standard for config files.

01

Getting Started

Basic Structure

YAML uses indentation (spaces, not tabs) for nesting. Mappings use key: value, sequences use - item. Inline syntax uses [] for lists and {} for maps. Comments use #.

yaml
# YAML uses indentation for structure
# Comments start with #

# Key-value pairs (mappings)
name: Alice
age: 30

# Nested mappings (use consistent indentation)
person:
  name: Bob
  age: 25
  address:
    city: NYC
    zip: 10001

# Sequences (lists)
hobbies:
  - reading
  - coding
  - music

# Inline syntax
numbers: [1, 2, 3]
coords: {x: 10, y: 20}

Comments

Comments start with # and must be preceded by whitespace or start of line. Inside block scalars (| and >), # is literal content, not a comment. There is no multi-line comment syntax — each line needs its own #.

yaml
# Full-line comment
key: value  # Inline comment (whitespace before # required)

# Comments can appear anywhere
# - before documents
# - inside sequences
list:
  - a  # first item
  - b  # second item

# Block scalar headers DO accept comments
text: |  # literal block
  line one

# But the content of block scalars is literal,
# so # inside is NOT a comment:
literal: |
  # this is part of the string, not a comment

Indentation Rules

YAML strictly forbids tabs for indentation — only spaces are allowed. Indentation must be consistent within a level but doesn't need to be a fixed number. The - in sequences is part of the indentation. Mixing tabs and spaces throws a parser error.

yaml
# Use SPACES only — tabs are forbidden for indentation
# 2 spaces is the most common convention

mapping:
  nested:
    deeply_nested: value

# Sequences can be indented at the same level as the key
items:
  - one
  - two

# Or indented further (both are valid)
items2:
    - one
    - two

# WRONG: tab characters cause parser errors
# bad:	value   (tab before value)

# Hyphen counts as indentation start
- item1
- item2

Document Start & End

--- marks the start of a YAML document; ... marks the end. A file can contain multiple documents separated by --- (a document stream). For single-document config files, a leading --- is optional but recommended as a hint to parsers.

yaml
# --- separates documents in a stream
# ... ends a document (optional)

---
name: first document
value: 1
...
---
name: second document
value: 2
...

# A single document needs no markers
key: value

# But --- is recommended at the start of files
# to disambiguate from plain text
---
apiVersion: v1
kind: ConfigMap

Common Pitfalls

The colon needs a following space to be a mapping separator. YAML 1.1 vs 1.2 differ on yes/no/on/off (booleans in 1.1, strings in 1.2) and on octal literals. Empty values are null, distinct from empty strings. Many parsers (PyYAML) still follow 1.1 rules.

yaml
# Colon needs a space after it
key:value      # WRONG: parsed as a single string "key:value"
key: value     # correct

# Yes/No/On/Off are NOT booleans in YAML 1.2
# (they WERE in YAML 1.1 — common gotcha)
answer: yes    # YAML 1.1: true; YAML 1.2: "yes" string

# Numbers with leading zeros
version: 010   # YAML 1.1: octal 8; YAML 1.2: string "010"

# Unquoted strings with special chars
url: example.com/path   # OK
url: example.com:8080   # WRONG: parsed as mapping with key example.com

# Empty values
empty:       # null
empty2: ~    # explicit null
empty3: ""   # empty string (different from null)

YAML vs JSON

YAML 1.2 was designed so JSON is a strict subset — any JSON file is valid YAML. YAML adds comments, block structure, anchors, multi-line strings and tags. JSON requires quoting all keys and string values, which YAML relaxes for plain scalars.

yaml
# JSON is a strict subset of YAML 1.2
# Every valid JSON document is valid YAML

# JSON:
{"name": "Alice", "age": 30, "hobbies": ["a", "b"]}

# Equivalent YAML:
name: Alice
age: 30
hobbies:
  - a
  - b

# YAML adds: comments, multi-line strings, anchors,
# relaxed quoting, tags, multiple documents

# YAML removes: mandatory quotes around strings,
# commas, braces (in block style)

# YAML is a superset of JSON — not the other way.
# Comments and anchors are not valid JSON.
02

Scalars (Strings, Numbers, Booleans, Null)

Plain & Quoted Strings

Plain strings need no quotes unless they look like numbers, booleans, null, or dates. Single quotes only escape ' by doubling (''); no other escapes. Double quotes support full escape sequences (\n, \t, \uXXXX). Quoting forces string type.

yaml
# Plain strings (no quotes) — most common
name: Alice
path: /usr/local/bin
url: https://example.com

# Single-quoted — escape ' by doubling
msg: 'it''s fine'

# Double-quoted — supports escapes
greeting: "Hello\nWorld"
unicode: "\u03B1"   # Greek alpha
tab: "col1\tcol2"

# Multi-word plain strings are fine
description: This is a plain string with spaces

# Strings that look like other types must be quoted
version: "1.0"      # string, not float
notBool: "true"     # string, not boolean
notNull: "null"     # string, not null
date: "2024-01-01"  # string, not date

Numbers

YAML 1.2 uses 0o for octal (YAML 1.1 used a bare leading 0). Underscores in numbers are allowed for readability. .inf and .nan are special float literals. Quote any value that should be a string but looks numeric (versions, phone numbers, IDs).

yaml
# Integers
int: 42
negative: -17
positive: +100
underscored: 1_000_000   # readable grouping (YAML 1.2)

# Octal & hex (YAML 1.2 prefixes)
octal: 0o17     # = 15 decimal
hex: 0xFF       # = 255 decimal
binary: 0b1010  # = 10 decimal

# Floats
pi: 3.14159
exp: 1.0e+3     # 1000.0
neg_exp: -2.5e-4

# Special float values (IEEE 754)
infinity: .inf
neg_infinity: -.inf
not_a_number: .nan

# To force a string that looks numeric:
version: "1.0"
phone: "555-1234"

Booleans

YAML 1.2 narrowed booleans to just true/false (and True/False, TRUE/FALSE). YAML 1.1 (used by PyYAML) treats yes/no/on/off/y/n as booleans — a famous source of bugs (e.g. Norway 'no' → false). Always quote these strings if you mean them literally.

yaml
# YAML 1.2 — only these are booleans:
enabled: true
disabled: false

# YAML 1.1 ALSO accepts (common gotcha):
# yes, no, on, off, y, n, Y, N
# These are strings in 1.2 but booleans in 1.1

# To force a string:
answer: "yes"      # always a string
option: "on"
flag: "y"

# PyYAML (1.1 rules) parses these as booleans:
# yes -> True, no -> False, on -> True, off -> False
# This breaks "no" as an answer or country code!

# Safe practice: quote if you mean the literal string
country: "no"   # Norway country code, not False

Null Values

YAML accepts null, Null, NULL, ~, and an empty value as null. An empty value (key with nothing after the colon) is null, distinct from an empty string "". Parsers map YAML null to the host language's null value (None, null, nil).

yaml
# All of these represent null/None/null
key1: null
key2: Null
key3: NULL
key4: ~
key5:             # empty value

# Distinguish null from empty string
empty_string: ""
null_value: null

# In sequences
list:
  - value
  - null         # explicit null item
  - another

# In nested structures
config:
  timeout: null   # explicitly unset
  retries: 3

# Parsers convert to language-specific null:
# Python -> None, JavaScript -> null, Go -> nil

Dates & Timestamps

ISO 8601 dates and timestamps are recognized as native types by most parsers. A bare YYYY-MM-DD becomes a date; with time it becomes a datetime. Quote the value to keep it as a string. Timezone designators (Z, +08:00) are supported.

yaml
# Date (ISO 8601)
date: 2024-01-15

# Datetime (ISO 8601)
datetime: 2024-01-15T14:30:00
datetime_tz: 2024-01-15T14:30:00Z
datetime_offset: 2024-01-15T14:30:00+08:00
datetime_space: 2024-01-15 14:30:00

# To force string (not a date):
not_a_date: "2024-01-15"
not_a_date2: '2024-01-15'

# Parsers typically return datetime objects
# Python: datetime.date / datetime.datetime
# JavaScript: Date (depending on library)

Type Inference

YAML's type system resolves unquoted scalars via a schema. The Failsafe schema treats everything as strings; the JSON and Core schemas infer int, float, bool, null, date. Use quotes or explicit tags (!!str, !!int) to override resolution when needed.

yaml
# YAML infers types from unquoted scalars
str: hello             # string
int: 42                # integer
float: 3.14            # float
bool: true             # boolean
null_val: null         # null
date: 2024-01-01       # date
list: [1, 2, 3]        # sequence

# Force string type with quotes
str_version: "42"
str_date: "2024-01-01"
str_bool: "true"

# Or with the !!str tag
forced: !!str 42        # string "42"
forced_int: !!int "42"  # integer 42

# Resolution depends on the schema
# Failsafe: everything is a string
# JSON/Core: numbers, bools, null inferred
03

Mappings

Simple Mapping

A mapping is key: value pairs where keys are typically strings. Keys can be any scalar (number, bool, even null). Duplicate keys are discouraged and behavior varies by parser (often last-wins). Use a linter to catch accidental duplicates.

yaml
# A mapping is a set of key: value pairs
name: Alice
age: 30
email: [email protected]

# Keys can be any scalar type (string, number, bool)
"string key": value
123: numeric key
true: boolean key

# Duplicate keys are technically discouraged
# but some parsers allow them (last wins)
key: first
key: second   # overrides "first" in many parsers

# Values can be any type
mixed:
  string: text
  number: 42
  list: [1, 2, 3]
  nested:
    deep: value

Nested Mappings

Nested mappings are created by indenting child keys. Indentation must be consistent within a level. There's no fixed indentation size, but 2 spaces is the de facto standard. Deeper nesting reduces readability — consider flattening very deep structures.

yaml
# Nest mappings with consistent indentation
server:
  host: localhost
  port: 8080
  ssl:
    enabled: true
    cert: /etc/ssl/cert.pem
    key: /etc/ssl/key.pem

database:
  primary:
    host: db1.example.com
    port: 5432
  replica:
    host: db2.example.com
    port: 5432

# Indentation level is up to you — just be consistent
# 2 spaces is the most common convention

Mapping of Sequences

Mappings and sequences compose freely. A common pattern is a sequence of mappings (a list of records), where each - starts an item and aligned keys belong to that item. Indentation of subsequent keys must align with the first key after the -.

yaml
# Values in a mapping can be sequences
servers:
  - web1.example.com
  - web2.example.com
  - web3.example.com

# Sequence of mappings (very common)
users:
  - name: Alice
    age: 30
  - name: Bob
    age: 25

# Each - starts a new item; aligned keys form one mapping
services:
  - name: api
    port: 8080
    env:
      - dev
      - prod
  - name: web
    port: 3000

# Mixed: mapping containing sequence containing mapping
config:
  endpoints:
    - path: /health
      method: GET
    - path: /users
      method: POST

Complex Keys

YAML allows any scalar or collection as a key using the ? key indicator. This is rare in config files but useful for lookup tables (e.g. pricing by region+instance). Complex keys are awkward to read — most config files stick to plain string keys.

yaml
# Keys can be complex objects using ? notation
? key1
: value1

# A mapping as a key
? host: localhost
  port: 8080
: connection1

# A sequence as a key
? [a, b, c]
: indexed_value

# Practical (rare) use: matrix lookup
? [us-east-1, t2.micro]
: 0.0116
? [us-east-1, t2.small]
: 0.023

# In flow style:
{[a, b]: value, {x: 1}: other}

# Most config files just use plain string keys
simple: value

Inline (Flow) Mappings

Flow style uses {} for mappings and [] for sequences, similar to JSON. Useful for short, compact data on one line. Unlike JSON, YAML does not allow trailing commas. Mix flow and block styles freely, but prefer block for readability in config files.

yaml
# Flow style mapping (like JSON)
point: {x: 10, y: 20}
config: {host: localhost, port: 8080}

# Nested flow mappings
nested: {a: {b: {c: value}}}

# Mixed flow and block
block_key:
  flow_value: {x: 1, y: 2}
  another: value

# Flow with sequences
mixed: {nums: [1, 2, 3], name: test}

# Quoting still works in flow style
quoted: {"key with space": "value", count: 3}

# Trailing comma is NOT allowed in YAML flow
bad: {a: 1, b: 2,}   # parse error

Key Ordering & Style

YAML itself preserves mapping order in the spec, but host-language objects may not (though most modern dicts do). For config readability, group related keys, use consistent naming (snake_case or camelCase), and add comments to delineate sections.

yaml
# YAML mappings are ORDERED (unlike JSON spec, though
# most JSON parsers preserve insertion order too)
# But language objects may not preserve order:
# Python dict (3.7+): preserves order
# Ruby Hash: preserves order
# JSON object: technically unordered

# Consistent key style improves readability
config:
  server:
    host: localhost      # snake_case
    port: 8080
    max_connections: 100
  database:
    host: db.example.com
    port: 5432
    connection_pool: 10

# Group related keys together
# Use comments to delineate sections
app:
  # networking
  host: 0.0.0.0
  port: 3000
  # limits
  timeout: 30
  retries: 3
04

Sequences

Block Sequences

Block sequences use - (hyphen + space) before each item. Items can be any type: scalars, mappings, or nested sequences. The - counts as part of the indentation, so subsequent keys in a mapping item align with the first key after the hyphen.

yaml
# A sequence (list) uses - followed by space
fruits:
  - apple
  - banana
  - cherry

# Sequence as top-level document
- item1
- item2
- item3

# Empty sequence
empty: []
or_with_marker: []

# Sequence with mixed types
mixed:
  - string
  - 42
  - true
  - null
  - key: value   # a mapping as an item

Nested Sequences

Nested sequences are formed by indenting further - levels. They get hard to read past 2 levels — switch to flow style for inner sequences. Tree structures (sequence of mappings with children) are more readable than pure nested sequences.

yaml
# Sequence of sequences
matrix:
  - - 1
    - 2
    - 3
  - - 4
    - 5
    - 6

# More readable with flow style for inner
matrix2:
  - [1, 2, 3]
  - [4, 5, 6]

# Deeply nested
tree:
  - name: root
    children:
      - name: child1
        children:
          - name: grandchild1
      - name: child2

# 3D: sequence of sequence of sequence
cube:
  - - [1, 2]
    - [3, 4]
  - - [5, 6]
    - [7, 8]]

Sequence of Mappings

A sequence of mappings is the most common YAML pattern (lists of records). Each - starts a new item; subsequent keys at the same indentation belong to that item. Align keys after the - for readability. An empty mapping item is written as {}.

yaml
# Most common pattern: list of records
users:
  - name: Alice
    email: [email protected]
    roles:
      - admin
      - user
  - name: Bob
    email: [email protected]
    roles:
      - user

# Each - starts a new mapping; aligned keys belong
# to that item. Indent the first key after - by 2 spaces.
servers:
  - host: web1.example.com
    port: 8080
    healthy: true
  - host: web2.example.com
    port: 8080
    healthy: false

# Empty mapping item
list:
  - {}
  - name: real

Inline (Flow) Sequences

Flow sequences use [] and are equivalent to block sequences. Useful for short lists on one line. Flow sequences can span multiple lines for readability. Unlike JSON, YAML forbids trailing commas. Mix flow sequences with block mappings freely.

yaml
# Flow sequence (JSON-like)
numbers: [1, 2, 3]
names: [Alice, Bob, Charlie]

# Nested flow sequences
matrix: [[1, 2], [3, 4]]

# Flow sequence of flow mappings
points: [{x: 1, y: 2}, {x: 3, y: 4}]

# Mixed: block mapping with flow sequence value
config:
  ports: [80, 443, 8080]
  hosts: [web1, web2, web3]

# Spread across multiple lines (still flow style)
long_list: [
  item1,
  item2,
  item3
]

# NO trailing comma allowed
bad: [1, 2, 3,]   # error

Mixed & Heterogeneous Content

YAML allows completely heterogeneous content in sequences and mappings — any item can be any type. This flexibility is powerful but means the application must validate structure. Use a schema (JSON Schema, Cue, etc.) for strict validation.

yaml
# Sequences can mix types freely
mixed:
  - string
  - 42
  - true
  - null
  - [1, 2, 3]            # nested sequence
  - key: value           # mapping
    another: value2
  - - nested
    - sequence

# Heterogeneous mapping values
config:
  string_val: text
  int_val: 42
  list_val: [a, b, c]
  map_val: {x: 1, y: 2}
  null_val: null
  bool_val: true

# YAML has no schema enforcing uniformity —
# the application must validate structure

Sequence Indentation Patterns

Sequences under a key can be indented (Pattern 1) or at the same level (Pattern 2, since - counts as indentation). Both are valid; pick one and be consistent. In sequences of mappings, all keys of an item must align with the first key after the -.

yaml
# Pattern 1: sequence indented under key
items:
  - a
  - b

# Pattern 2: sequence at same level as key
# (the - is treated as indentation)
items:
- a
- b

# Both are valid and produce the same result.
# Pick one and be consistent (most style guides prefer #1).

# Mapping item alignment — both keys align with first
users:
  - name: Alice
    age: 30
  - name: Bob
    age: 25

# WRONG: misaligned keys cause parse errors
# users:
#   - name: Alice
#    age: 30    # one space off — error
05

Multi-line Strings (Folded & Literal)

Literal Block Scalar (|)

The literal block scalar | preserves every newline exactly as written. Indentation (the amount past the |) is stripped from each line. Default chomping (clip) keeps one trailing newline. Used for scripts, code, poetry, ASCII art — anywhere literal newlines matter.

yaml
# | preserves newlines as-is
script: |
  #!/bin/bash
  echo "Hello"
  echo "World"

# Output string:
# #!/bin/bash\necho "Hello"\necho "World"\n

poem: |
  Roses are red,
  Violets are blue,
  YAML is clean,
  And JSON is too.

# Trailing newline is kept by default (clip mode)
# Use |- to strip, |+ to keep all trailing newlines

Folded Block Scalar (>)

The folded block scalar > converts single newlines into spaces, joining lines into paragraphs. Blank lines become literal newlines. Lines that are more indented than the block content are preserved literally (useful for code samples inside prose).

yaml
# > folds newlines into spaces (paragraphs)
description: >
  This is a long paragraph that
  spans multiple lines in the YAML
  but will be joined into one line
  when parsed.

# Output: "This is a long paragraph that spans multiple
# lines in the YAML but will be joined into one line when
# parsed.\n"

# Blank lines preserve a newline
paragraphs: >
  First paragraph here.

  Second paragraph here.

  Third paragraph here.

# More indented lines are preserved literally
example: >
  This is folded.
    This line is more indented — kept literal.

Chomping Indicators (+/-)

Chomping controls trailing newlines: clip (default, no indicator) keeps one; strip (-) removes all; keep (+) preserves all. The indicator goes right after | or > (e.g. |-, |+, >-, >+). Stripping is useful for strings used inline; keeping is rare but matches the source exactly.

yaml
# Default (clip): single trailing newline
text: |
  line1
  line2
# Result: "line1\nline2\n"

# Strip (-): remove ALL trailing newlines
text_strip: |-
  line1
  line2
# Result: "line1\nline2"

# Keep (+): keep ALL trailing newlines
text_keep: |+
  line1
  line2


# Result: "line1\nline2\n\n\n"

# Same applies to folded scalars
folded_keep: >+
  folded
  text

Indentation Indicator

When block scalar content starts with whitespace, you must specify the content indentation explicitly with a digit (e.g. |2). The digit indicates how many spaces of indentation belong to the structure vs. the content. Combine with chomping: |2-, >4+, etc. Rarely needed but essential when it is.

yaml
# When the first line starts with spaces, you must
# tell YAML the content indentation explicitly

# Without indicator (ambiguous):
# content:
#   indented_line   # is the leading space content or not?

# With explicit indentation indicator (digit after | or >)
literal: |2
    hello
      world
# The "2" means: content is indented 2 spaces past the indicator
# So 4 spaces of indentation = 2 content + 2 extra

# Combined with chomping (order: indent then chomp)
text: |2-
    no trailing newline

# Folded with explicit indent
folded: >4
    paragraph with
    explicit indent

# Most of the time you don't need this — only when
# content legitimately starts with spaces

Plain Multi-line Strings

Plain and quoted scalars can span multiple lines, with newlines folded to spaces (similar to > but implicit). This is fragile because line breaks depend on context and special characters. For any non-trivial multi-line text, prefer explicit | (literal) or > (folded) block scalars.

yaml
# Plain (unquoted) scalars can span lines
# Newlines are folded to spaces (like > but no indicator)
message: this is
  a long message
  on multiple lines
# Result: "this is a long message on multiple lines"

# Quotes are required if the string contains special chars
quoted: "this is also
  a long message
  on multiple lines"
# Result: "this is also a long message on multiple lines"

# Plain multi-line breaks at certain chars
# (colons, etc.) — usually fold cleanly
# but be careful with leading colons

# Plain is fragile for multi-line — prefer | or >
safe_literal: |
  Use literal block for
  predictable multi-line text

Choosing Fold vs Literal

Rule of thumb: use | when newlines are significant (scripts, config files, code), use > when you want readable multi-line source but a joined paragraph (descriptions, documentation). Use |- to strip the trailing newline when the string is used inline.

yaml
# Use | (literal) when newlines matter:
script: |
  #!/bin/bash
  set -e
  echo "Step 1"
  echo "Step 2"

config_file: |
  [default]
  region=us-east-1
  output=json

# Use > (folded) when you want readable source
# but a single paragraph string:
description: >
  This service handles user authentication
  and session management. It talks to the
  PostgreSQL database and Redis cache.

# Use |- or >- to strip trailing newline
# (useful for inline string usage):
inline_script: |-
  echo hello

# Use plain single-line for short strings
short: hello world
06

Anchors & Aliases

Defining Anchors (&)

&name defines an anchor on a node (mapping, sequence, or scalar). The anchor doesn't change the value at that location — it just labels the node for reuse. Aliases (*name) reference the anchored node by name. Anchors must be defined before use within the document.

yaml
# &name defines an anchor on a node
defaults: &defaults
  adapter: postgres
  host: localhost
  port: 5432

# The anchor marks the node so it can be reused
# The value is still assigned normally
production:
  database: *defaults
  # *defaults is an alias — references the anchored node

# Anchors on scalars
version: &ver "1.0.0"
current: *ver

# Anchors on sequences
colors: &colors
  - red
  - green
  - blue
primary: *colors

Using Aliases (*)

*name is an alias that references a previously-anchored node. Aliases share identity with the original (same object in most parsers). Anchors/aliases are document-scoped — they can't reference across --- document boundaries. Combine with << (merge key) to layer overrides.

yaml
# Define once, reuse by alias
base: &base
  image: nginx:1.25
  restart: always

web:
  <<: *base
  ports:
    - "80:80"

api:
  <<: *base
  ports:
    - "8080:8080"
  environment:
    - DEBUG=true

# Aliases reference the SAME object (by identity)
# In most parsers, mutating one mutates the other
# (though YAML itself is read-only)

# Aliases can't span documents
# Each document has its own anchor namespace

Anchor Scope

Anchors are scoped to their document and must be defined before any alias references them (no forward references). Multiple aliases can reference the same anchor. Aliasing an alias works — it resolves transitively. Cross-document references are an error.

yaml
# Anchors are scoped to a single document
---
# Document 1
shared: &shared
  key: value
use: *shared   # works
...
---
# Document 2 — &shared is NOT visible here
other: *shared   # ERROR: unknown anchor

# Anchors must be defined before the alias
# (forward references are not allowed)
bad: *later      # ERROR
later: &later value

# Multiple aliases to the same anchor are fine
template: &tpl
  timeout: 30
use1: *tpl
use2: *tpl
use3: *tpl

# Aliasing an alias is allowed
a: &a value
b: &b *a
c: *b   # resolves to "value"

Overriding Anchored Values

An alias (*name) replaces the entire node — you can't partially override it. To override individual keys of an anchored mapping, use the merge key << to merge the anchored mapping and then add or override keys. This is the canonical pattern for configuration composition.

yaml
# Aliases themselves can't be partially overridden —
# an alias replaces the whole node. Use << (merge)
# to combine an anchored mapping with overrides.
defaults: &defaults
  timeout: 30
  retries: 3
  log_level: info

production:
  <<: *defaults
  log_level: warn      # override
  host: prod.example.com   # add

# Result:
# production:
#   timeout: 30
#   retries: 3
#   log_level: warn
#   host: prod.example.com

# Without <<, an alias replaces the whole node:
wrong:
  thing: *defaults
  # thing is the FULL defaults mapping — can't override

Anchors in Sequences

Anchors can label entire sequences, individual sequence items, or mappings inside a sequence. The << merge pattern is especially useful in sequences of similar records (e.g. service definitions) to share common fields. Anchor individual items with - &name value.

yaml
# Anchors work on sequence items too
common: &common
  enabled: true
  log: stdout

services:
  - name: web
    <<: *common
    port: 80
  - name: api
    <<: *common
    port: 8080
  - name: worker
    <<: *common
    queue: jobs

# Anchoring a whole sequence
endpoints: &endpoints
  - /health
  - /metrics
  - /ready

monitoring:
  checks: *endpoints

# Anchoring individual sequence items
list:
  - &first one
  - two
  - *first   # "one" again

Anchor Caveats

Anchors have caveats: aliases share identity (mutating one affects all references in mutable parsers), they add cognitive overhead, and most serializers (including JSON output) expand them to plain copies. Use anchors for genuine reuse (templates, defaults), not micro-optimization.

yaml
# Caveat 1: shared identity can surprise you
defaults: &defaults
  list: [1, 2, 3]

a: *defaults
b: *defaults
# In mutable parsers, a.list and b.list are the SAME list.
# Editing a.list also changes b.list.

# Caveat 2: anchors add cognitive overhead
# Overuse makes YAML hard to read and trace.

# Caveat 3: not all tools preserve anchors on output
# Many serializers expand aliases to plain copies.

# Caveat 4: JSON output loses anchors entirely
# (JSON has no anchor concept).

# Caveat 5: anchors don't work across documents
---
doc1: &x value
use1: *x
---
doc2: *x   # ERROR

# Prefer anchors for genuine reuse, not micro-optimization.
07

Merge Keys (<<)

Basic Merge (<<)

The merge key << takes a mapping (usually an alias) and merges its key-value pairs into the current mapping. Keys explicitly set in the current mapping override merged ones. This is the canonical pattern for configuration inheritance in YAML.

yaml
# << merges a referenced mapping into the current one
defaults: &defaults
  adapter: postgres
  pool: 5
  timeout: 5000

development:
  <<: *defaults
  database: myapp_dev
  host: localhost

production:
  <<: *defaults
  database: myapp_prod
  host: db.example.com
  pool: 20      # override

# Result for development:
# adapter: postgres
# pool: 5
# timeout: 5000
# database: myapp_dev
# host: localhost

Multiple Merges

<< can merge multiple mappings by passing a flow sequence of aliases ([*a, *b, *c]). Merges apply left-to-right; later merges override earlier ones for duplicate keys. Explicit keys in the mapping always win over any merged key. Multiple << entries are also allowed.

yaml
# << can merge multiple mappings (sequence value)
base: &base
  logging: info
  retries: 3

security: &security
  auth: required
  tls: true

service: &service
  port: 8080
  host: 0.0.0.0

# Merge multiple in one go
production:
  <<: [*base, *security, *service]
  environment: prod
  port: 80        # override service.port

# Later merges override earlier ones for the same key.
# Explicit keys always win over merges.

# You can also have multiple << entries:
combined:
  <<: *base
  <<: *security
  custom: value

Override & Precedence

Merge precedence: explicit keys in the mapping always win. Among merged mappings, later ones in the sequence override earlier ones for the same key. Use this to layer defaults → role-specific → instance-specific overrides cleanly.

yaml
# Explicit keys override merged keys
template: &template
  port: 8080
  log: info
  timeout: 30

instance:
  <<: *template
  port: 9090        # overrides template.port
  log: debug        # overrides template.log
  host: example.com # added

# Final: {port: 9090, log: debug, timeout: 30, host: example.com}

# Precedence: explicit > later merge > earlier merge
a: &a
  k: from_a
  x: 1
b: &b
  k: from_b
  y: 2

result:
  <<: [*a, *b]
  k: explicit   # wins over both a and b
# Final: {k: explicit, x: 1, y: 2}

Merge Pattern with Anchors

The merge+anchor pattern is the bread and butter of Docker Compose and similar config files: define a defaults anchor, then each service merges it and overrides individual fields. This keeps configs DRY without sacrificing per-service customization.

yaml
# Common pattern: anchor templates, merge + override
defaults: &defaults
  image: nginx:1.25
  restart: always
  logging:
    driver: json-file

services:
  web:
    <<: *defaults
    ports: ["80:80"]
  api:
    <<: *defaults
    image: node:20      # override
    ports: ["8080:8080"]
    environment:
      NODE_ENV: production
  worker:
    <<: *defaults
    image: worker:latest
    command: ["./run.sh"]

# This is the bread-and-butter of Docker Compose files
# — share base config, override per service.

Merge Limitations

The merge key << only works for mappings — sequences and scalars can't be merged. Merge is shallow: a nested mapping in the current node REPLACES the merged one entirely (no deep merge). For deep merging, restructure the data or do it in application code.

yaml
# << only works for MAPPINGS, not sequences or scalars
list_template: &list [1, 2, 3]

# This does NOT merge list items:
combined:
  <<: *list      # WRONG: *list is a sequence, not a mapping
  extra: value   # parse error or unexpected behavior

# Deep merge is NOT supported
parent: &parent
  nested:
    a: 1
    b: 2

child:
  <<: *parent
  nested:
    c: 3   # REPLACES parent.nested entirely
# Result: {nested: {c: 3}}  — NOT {nested: {a:1, b:2, c:3}}

# To deep-merge, you must do it in application code
# or restructure the data.

Merge Key Status & Deprecation

The << merge key was a YAML 1.1 extension removed from the YAML 1.2 core spec. In practice, most parsers still support it (PyYAML, js-yaml, ruamel, SnakeYAML), but strict 1.2 parsers reject it. Use it today, but be aware it's non-standard; for new formats consider config languages with real inheritance (Cue, Jsonnet, Dhall).

yaml
# IMPORTANT: the << merge key was REMOVED in YAML 1.2
# It was a YAML 1.1 feature.

# In practice, many parsers still support it:
# - PyYAML: supports (1.1 mode)
# - js-yaml: supports (opt-in)
# - ruamel.yaml: supports
# - SnakeYAML: supports
# - LibYAML: supports

# But strict YAML 1.2 parsers reject it:
# new_yaml_1_2_strict:
#   <<: *template    # ERROR in strict 1.2

# Recommendation:
# - Use << for now (widely supported)
# - Document that your files rely on it
# - For new formats, consider composition in code
#   or a config language with proper inheritance
#   (e.g. Dhall, Cue, Jsonnet)

# Alternative without <<:
# just duplicate or use application-level templating
08

Type Tags

Built-in Tags

Built-in tags use the !! prefix (shorthand for tag:yaml.org,2002:). They force a specific type regardless of schema inference. Common tags: !!str, !!int, !!float, !!bool, !!null, !!binary, !!map, !!seq, !!omap, !!set, !!timestamp. Useful when inference would guess wrong.

yaml
# YAML tags use !! prefix for built-in types
# (the long form is tag:yaml.org,2002:)

# Common built-in tags
str: !!str hello
int: !!int 42
float: !!float 3.14
bool: !!bool true
null: !!null null
binary: !!binary "SGVsbG8="
map: !!map {a: 1}
seq: !!seq [1, 2]
omap: !!omap
  - a: 1
  - b: 2
set: !!set
  ? item1
  ? item2
timestamp: !!timestamp 2024-01-01T00:00:00Z
merge: !!merge "<<"   # the merge key tag

# Tags force a specific type regardless of inference
forced_str: !!str 42        # "42" string
forced_int: !!int "42"      # 42 integer
forced_bool: !!bool "yes"   # true

Forcing Types

Tags force a specific type, overriding schema inference. The most common use is !!str to prevent strings that look like numbers/booleans/dates from being coerced. !!int and !!float can parse numeric strings. Tags are essential when the host application needs a specific type.

yaml
# Without tags, type depends on schema inference
value: 42          # integer (Core schema)
value: "42"        # string (quoted)
value: !!str 42    # string (forced by tag)

# Common forcing scenarios
version: !!str 1.0       # "1.0" string, not float
phone: !!str 5551234     # string, not int
answer: !!str yes        # string, not bool (1.1)
region: !!str no         # string "no", not False (1.1)

# Force numeric parsing from strings
count: !!int "100"       # 100 integer
ratio: !!float "0.5"     # 0.5 float

# Force bool from strings
enabled: !!bool "yes"    # true
enabled: !!bool "off"    # false

# Force null
empty: !!null ""         # null
empty: !!null "~"        # null

Custom Tags

Custom tags use a single ! prefix and are application-defined. The host application must register a constructor for each tag. Safe loaders (recommended) reject unknown tags by default — you must explicitly opt in. Library-specific tags (like PyYAML's python/object) can be dangerous and are disabled by safe loaders.

yaml
# Application-specific tags use ! prefix
# The application's YAML loader must handle them

# Custom tag (application-defined)
person: !person
  name: Alice
  age: 30

# Tag with arguments
color: !color rgb(255, 0, 0)
point: !point [10, 20]

# Library-specific tags (PyYAML example)
# python/object/apply:module.func args
sorted_set: !!python/object/apply:sorted
  args:
    - [3, 1, 2]

# Ruby-specific (Psych)
# !ruby/object:ClassName

# Custom tags require custom constructors in code.
# Unknown tags usually cause errors in safe loaders.

Local & Verbatim Tags

!prefix is for local tags (application-defined), !! is shorthand for the yaml.org global tags, !<uri> is verbatim (no resolution), and !prefix!short uses %TAG-declared prefixes for brevity. Local tags are most common; verbatim tags matter for cross-tool schemas.

yaml
# ! prefix — local tag (application-specific, no URI)
data: !myapp/data
  value: 42

# !! prefix — global built-in tag (resolved to yaml.org)
str: !!str hello

# !<full-uri> — verbatim tag (exact URI, no resolution)
binary: !<tag:example.com,2024:binary> "data"

# !tag!short — tag with prefix (resolved via %TAG directive)
%TAG !e! tag:example.com,2024:
data: !e!custom value
# resolves to tag:example.com,2024:custom

# %TAG directives declare prefixes
%TAG !yaml! tag:yaml.org,2002:
value: !yaml!str 42   # same as !!str

# Local tags are most common in app-specific YAML
# Verbatim tags are useful for cross-tool interoperability

Tag Resolution

Tag resolution depends on the schema. Failsafe (minimal) treats everything as strings unless tagged. JSON schema adds int/float/bool/null. Core schema (YAML 1.2 default) extends JSON with more lenient matching. Explicit tags (!!) always override schema resolution.

yaml
# Tags are resolved by the schema (Core, JSON, Failsafe)
# Without explicit tags, the schema infers types

# Failsafe schema — only str, map, seq (no inference)
# Everything is a string unless tagged
failsafe_value: 42       # str "42"
failsafe_tagged: !!int 42  # int 42

# JSON schema — str, int, float, bool, null, map, seq
json_value: 42           # int
json_str: "42"           # str

# Core schema (default for YAML 1.2) — like JSON but
# with more lenient parsing (yes/no not bool, etc.)
core_value: 42           # int
core_bool: true          # bool
core_str: hello          # str

# Explicit tags override schema resolution
forced: !!str true       # str "true" (even in Core)

Application-Specific Tags

Application-specific tags let you embed domain objects (Money, DateRange, etc.) in YAML. The loader must register a constructor for each tag. Always use safe_loaders that reject unknown tags by default — never let untrusted YAML invoke arbitrary constructors (a classic deserialization vulnerability).

yaml
# Define your own tags for domain objects
# Example: a config loader for a custom app

# Tag a node as a "Money" object
price: !money "USD 19.99"

# Tag a node as a "DateRange"
range: !dateRange
  start: 2024-01-01
  end: 2024-12-31

# Tag nodes for custom parsing/validation
settings: !strict
  host: localhost
  port: 8080

# PyYAML example: register a constructor
# class MoneyLoader:
#     def __init__(self, value):
#         self.currency, self.amount = value.split()
#         self.amount = float(self.amount)
#
# yaml.add_constructor('!money', lambda l, n:
#     MoneyLoader(l.construct_scalar(n)))

# Safe loaders reject unknown tags — opt-in explicitly
# to avoid processing untrusted input with side effects.
09

Document Markers & Directives

Single Document

A single YAML document works fine without any markers. A leading --- is a convention to signal 'this is YAML' and disambiguate from plain text. The ... end marker is optional and rarely used for single-document files.

yaml
# A single YAML document needs no markers
key: value
list:
  - a
  - b

# But a leading --- is a common convention
# to mark "this is YAML, not plain text"
---
key: value

# The trailing ... (end marker) is optional
# for a single document
key: value
...

Multiple Documents (Stream)

A YAML stream is a sequence of documents separated by ---. Each document is parsed independently. Streams are common in Kubernetes (multiple objects per file), static site generators, and log files. Use load_all() or equivalent in your parser to iterate documents.

yaml
# A YAML stream is multiple documents separated by ---
---
name: first
value: 1
---
name: second
value: 2
---
name: third
value: 3

# Each document is independent — anchors don't cross
# Use a parser's load_all() / load_multi() to read them
# Python (PyYAML):
#   for doc in yaml.safe_load_all(stream):
#       print(doc)

# Common use cases:
# - Kubernetes: multiple objects in one file
# - Blog generators: front matter + content
# - Log files: one record per document
# - CI/CD: multi-step pipelines

Document End Marker (...)

The ... marker explicitly ends a document. It's optional in most cases but useful when content could be ambiguous (e.g. a literal block containing ---). It also signals end-of-document to streaming parsers. Rare in hand-written YAML but common in programmatic output.

yaml
# ... explicitly ends a document
---
key: value
...
# Anything after ... is a new document or stream end

# Useful when a document's content might be ambiguous
# without an explicit end:
---
message: |
  Some content
  ---
  that looks like a separator
...
---
next: document

# Without ... the parser might think the ---
# inside the literal block is a document separator
# (though block scalars usually prevent this)

# ... is also useful in programmatic output to flush

Directives

Directives appear before the document content and configure the parser. %YAML sets the version; %TAG declares tag prefix shortcuts. Directives are document-scoped — they apply only to the next document. Most config files omit directives and rely on parser defaults (usually 1.1 or 1.2 bimodal).

yaml
# Directives appear at the very top, before content
# %YAML — declare YAML version
%YAML 1.2
---
key: value

# %TAG — declare a tag prefix
%TAG !e! tag:example.com,2024:
---
data: !e!custom value

# Multiple directives are allowed
%YAML 1.2
%TAG !e! tag:example.com,2024:
%TAG !yaml! tag:yaml.org,2002:
---
value: !yaml!str hello

# Directives are document-scoped — they apply to the
# next document only and must be repeated for each.
# Most config files omit directives and use defaults.

Document Stream Examples

Document streams are widely used. Kubernetes uses them to bundle multiple objects (Namespace, Deployment, Service) in one file. Helm templates, CI logs, and static site generators (front matter + content) also use streams. Each document is independent and can have its own directives.

yaml
# Kubernetes multi-object file
---
apiVersion: v1
kind: Namespace
metadata:
  name: production
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: production
spec:
  replicas: 3
---
apiVersion: v1
kind: Service
metadata:
  name: api
  namespace: production
spec:
  ports:
    - port: 80

# Helm chart templates: stream of objects
# CI logs: one event per document
# Static site generator: front matter + body

Boundary Pitfalls

A line starting with --- at column 0 is always a document separator (outside block scalars). Inside block scalars (|, >), --- is literal content. To include --- at column 0 in a value, indent the block or end the document with ... first. A --- with content on the same line is a document start marker, not a separator.

yaml
# A line starting with --- is ALWAYS a document separator
# (when at column 0). This can surprise you:

---
script: |
  echo hello
  ---  # this is INSIDE the block scalar — safe
next: value

# But this is NOT safe:
---
content: |
  line1
---
mistaken: this is a NEW document, not part of content!

# Inside block scalars, --- is literal (good).
# Outside, --- at column 0 always separates documents.

# Workaround for content that must contain --- at col 0:
# indent the block, or use ... to end the doc first.

# Also: a bare --- line with content on the same line
# is the document start marker, NOT a separator
--- key: value   # this is one document
10

Flow Style

Flow Sequences

Flow sequences use [ ] and behave like JSON arrays. Whitespace and newlines inside are flexible. Unlike JSON, trailing commas are NOT allowed. Useful for short, dense lists. Quote items that contain special characters (commas, brackets, colons).

yaml
# Flow sequences use [item1, item2, ...]
numbers: [1, 2, 3]
names: [Alice, Bob, Charlie]

# Empty
empty: []

# Nested flow sequences
matrix: [[1, 2], [3, 4]]

# Flow sequence of mappings
points: [{x: 1, y: 2}, {x: 3, y: 4}]

# Multi-line flow sequence (whitespace is flexible)
long: [
  alpha,
  beta,
  gamma
]

# Quoting inside flow
mixed: ["hello", 'world', 42, true]

# NO trailing comma
bad: [1, 2, 3,]   # parse error

Flow Mappings

Flow mappings use { } and behave like JSON objects. Keys can be quoted when they contain special characters. Whitespace is flexible. Trailing commas are forbidden (unlike JSON). Use flow mappings for short, dense data; block style is more readable for larger structures.

yaml
# Flow mappings use {key: value, ...}
point: {x: 10, y: 20}
config: {host: localhost, port: 8080}

# Empty mapping
empty: {}

# Nested flow mappings
nested: {a: {b: {c: value}}}

# Quoted keys (needed for special chars)
quoted: {"key with space": value, "weird:key": 1}

# Multi-line flow mapping
config: {
  host: localhost,
  port: 8080,
  debug: true
}

# Flow mapping with flow sequence value
mixed: {nums: [1, 2, 3], name: test}

# NO trailing comma
bad: {a: 1, b: 2,}   # parse error

Mixed Flow & Block

Flow and block styles can be mixed freely within a document. The pragmatic rule: use block style for structural elements (better readability with indentation) and flow style for short inline values (lists of scalars, small maps). Avoid deeply nested flow — it becomes unreadable quickly.

yaml
# Mix flow and block styles freely
config:
  name: myapp
  ports: [80, 443]        # flow sequence in block mapping
  endpoints: {api: /v1, web: /}  # flow mapping in block

# Block inside flow (less common but valid)
flow_with_block: [{name: Alice, roles: [admin, user]}, {name: Bob}]

# Flow at top level
[top, level, flow]

# Block at top level
- block
- sequence

# Pragmatic rule:
# - Block for structure (readability)
# - Flow for short inline values
# - Avoid deeply nested flow — it becomes unreadable

Nested Flow

Flow style can nest arbitrarily deep, but each level adds brackets/braces that become hard to read. After 1-2 levels of nesting, switch to block style for readability. The block equivalent of deeply nested flow is almost always clearer for human maintainers.

yaml
# Flow can nest arbitrarily
data: {users: [{name: Alice, tags: [a, b]}, {name: Bob}]}

# Equivalent block:
# data:
#   users:
#     - name: Alice
#       tags:
#         - a
#         - b
#     - name: Bob

# Multi-line nested flow (rare but valid)
matrix: {
  rows: [
    [1, 2, 3],
    [4, 5, 6]
  ],
  cols: 3
}

# Recommendation: switch to block style after 1-2 levels
# of nesting — flow becomes a "wall of brackets".

When to Use Flow Style

Use flow style for short lists of scalars, small lookup maps, single-line records, and JSON-compatible output. Avoid flow for long lists (hard to scan), deeply nested structures (wall of brackets), or anything that needs inline comments. When in doubt, prefer block style for readability.

yaml
# Good uses of flow style:

# 1. Short lists of scalars
ports: [80, 443, 8080]
colors: [red, green, blue]

# 2. Small lookup maps
codes: {200: OK, 404: NotFound, 500: Error}

# 3. Single-line records in a sequence
coordinates:
  - {x: 1, y: 2}
  - {x: 3, y: 4}
  - {x: 5, y: 6}

# 4. JSON-compatible output (flow is JSON-compatible)

# Bad uses of flow style:

# 1. Long lists (use block)
#   ports: [80, 443, 8080, 8443, 9000, 9090, 9091, ...]  # hard to read

# 2. Deeply nested (use block)
#   {a: {b: {c: {d: {e: value}}}}}  # unreadable

# 3. Anything with comments (flow doesn't allow inline comments well)

Flow Style Caveats

Flow style has caveats: special characters (commas, colons, brackets) need quoting; no trailing commas; comments are awkward; no | or > block scalars (use \n in double-quoted strings); and single-line changes are harder to diff. Block style is friendlier for version-controlled config.

yaml
# Caveat 1: special characters need quotes in flow
flow: [a, b, "c,d", "d:e"]   # commas and colons need quoting
block:
  - a
  - b
  - c,d      # no quotes needed in block style
  - d:e      # no quotes needed in block style

# Caveat 2: no trailing commas
bad: [1, 2, 3,]   # error

# Caveat 3: comments are awkward in flow
config: {
  host: localhost,  # comment allowed but clunky
  port: 8080
}

# Caveat 4: flow style has no multi-line strings
# (no | or > inside flow)
# You can use \n escapes in double quotes:
text: "line1\nline2"

# Caveat 5: flow is harder to diff in version control
# (one-line changes vs whole-block changes)
11

YAML & JSON Conversion

JSON is Valid YAML

YAML 1.2 was designed so JSON is a strict subset — any JSON document is valid YAML. Most YAML parsers happily read JSON. The reverse isn't true: YAML features like comments, anchors, multi-document streams, and block scalars are not valid JSON.

yaml
# JSON is a strict subset of YAML 1.2
# Any JSON file is valid YAML

# This is valid YAML (it's JSON):
{"name": "Alice", "age": 30, "hobbies": ["a", "b"]}

# So is this multi-line JSON:
{
  "name": "Alice",
  "age": 30,
  "hobbies": ["a", "b"]
}

# Parsers usually accept JSON files as YAML
# Python: yaml.safe_load(json_string)  # works
# JS:     YAML.load(json_string)       # works
# Go:     yaml.Unmarshal(json_bytes, &v)  # works (yaml.v3)

# But the reverse is NOT always true:
# YAML with comments, anchors, multi-docs is NOT valid JSON

Equivalent Representations

JSON, YAML block, and YAML flow can all represent the same data. JSON is the most portable (every language has a JSON parser). YAML block is most readable for humans. YAML flow is a compact middle ground. Pick based on your audience: tools (JSON) vs humans (YAML block).

yaml
# JSON:
{"name": "Alice", "age": 30, "hobbies": ["a", "b"]}

# YAML block:
name: Alice
age: 30
hobbies:
  - a
  - b

# YAML flow (closest to JSON):
{name: Alice, age: 30, hobbies: [a, b]}

# YAML with quotes (JSON-compatible):
{"name": "Alice", "age": 30, "hobbies": ["a", "b"]}

# All four parse to the same data structure.
# Pick the style that fits your use case:
# - JSON for interop and strict tools
# - YAML block for human-edited config
# - YAML flow for compact inline data

JSON to YAML Conversion

Convert JSON to YAML by parsing JSON then dumping as YAML with default_flow_style=False (block style). JSON's mandatory quotes are dropped where YAML doesn't need them. Watch for keys that look like numbers/booleans — they may need quoting in YAML to preserve string type.

yaml
# Convert JSON to YAML using a parser
# Python:
# import json, yaml
# data = json.loads(json_string)
# yaml_str = yaml.safe_dump(data, default_flow_style=False, sort_keys=False)
#
# JS:
# const data = JSON.parse(jsonString)
# const yamlStr = yaml.dump(data)

# Example input JSON:
# {"server": {"host": "localhost", "port": 8080}, "debug": true}

# Output YAML:
# server:
#   host: localhost
#   port: 8080
# debug: true

# Conversion considerations:
# - JSON keys are always quoted -> YAML usually unquotes them
# - JSON arrays -> YAML block sequences (with default_flow_style=False)
# - JSON strings with special chars may need quoting
# - JSON null -> YAML null (or ~)
# - JSON booleans/numbers preserved

YAML to JSON Conversion

Convert YAML to JSON by parsing then JSON-serializing. Caveats: comments and tags are lost; anchors are expanded to plain copies; multi-document streams need to become a JSON array; YAML dates may become strings. Tools like yq make this easy on the command line.

yaml
# Convert YAML to JSON
# Python:
# import json, yaml
# data = yaml.safe_load(yaml_string)
# json_str = json.dumps(data, indent=2)

# JS:
# const data = yaml.load(yamlString)
# const jsonStr = JSON.stringify(data, null, 2)

# Command-line tools:
# yq -o=json '.config' file.yaml        # mikefarah/yq
# ruby -ryaml -rjson -e 'puts JSON.generate(YAML.load(STDIN.read))' < file.yaml
# python -c 'import json,sys,yaml; print(json.dumps(yaml.safe_load(sys.stdin)))'

# Caveats when converting YAML to JSON:
# - YAML comments are LOST (JSON has no comments)
# - YAML anchors are EXPANDED to plain copies
# - YAML multi-doc streams become a JSON array (or fail)
# - YAML tags (!!) are lost or cause errors
# - YAML dates may become strings (JSON has no date type)

JSON-Compatible Subset

The JSON-compatible subset of YAML avoids comments, anchors, multi-docs, tags, block scalars, and plain strings that look like other types. Staying in this subset guarantees clean round-trips to JSON. Useful when both YAML and JSON consumers read the same data, or when strict type preservation matters.

yaml
# To produce YAML that round-trips to JSON cleanly,
# stay within the JSON-compatible subset:

# 1. No comments
# 2. No anchors/aliases
# 3. No multi-doc streams
# 4. No tags
# 5. No block scalars (|, >) — use \n in double-quoted strings
# 6. Quote all string keys (JSON requires this)
# 7. No plain strings that look like other types

# JSON-compatible YAML:
{"name": "Alice", "age": 30, "active": true}
{"items": ["a", "b", "c"]}
{"nested": {"key": "value"}}

# Useful when you need both YAML and JSON consumers
# for the same data, or want strict type preservation.

# Many YAML dumpers have a "json-compatible" mode.

Round-tripping Considerations

Round-tripping YAML (parse then dump) often loses comments, anchor names, key order, quoting style, and formatting. ruamel.yaml (Python) has a round-trip mode that preserves comments and order. For human-edited config, never blindly overwrite with dumper output — diff carefully first.

yaml
# Round-trip: YAML -> parse -> dump -> YAML
# Information that may be LOST:
# - Comments (most dumpers drop them)
# - Anchor names (dumpers may re-create or expand)
# - Key order (some dumpers sort keys)
# - Quoting style (dumpers choose their own)
# - Flow vs block style (dumpers usually pick one)
# - Whitespace and indentation amount
# - Multi-document structure (sometimes)
# - Tag prefixes and %TAG directives

# Tools that preserve style:
# - ruamel.yaml (Python): round-trip mode preserves comments/order
# - yaml.v3 (Go): preserves key order
# - prettier with yaml plugin: normalizes formatting

# For config files where humans edit:
# - Use ruamel.yaml round-trip mode
# - Or accept that dumps are "normalized"
# - Never overwrite human-edited files with dumper output
#   without diffing carefully
12

YAML Schemas

Failsafe Schema

The Failsafe schema is the most conservative — it recognizes only str, map, and seq. All scalars are strings unless explicitly tagged. Useful for untrusted input (no implicit coercion surprises) or as a base for custom schemas. Most production loaders use a slightly richer schema.

yaml
# Failsafe schema — the most conservative
# Recognizes ONLY three types: str, map, seq
# Everything else is a string

# Under Failsafe:
value: 42           # str "42" (NOT int)
flag: true          # str "true" (NOT bool)
empty: null         # str "null" (NOT null)
date: 2024-01-01    # str "2024-01-01" (NOT date)

# Use explicit tags to get other types
int_value: !!int 42
bool_value: !!bool true
null_value: !!null null

# When to use Failsafe:
# - Untrusted input (no implicit type coercion)
# - When you want strings only
# - As a base for custom schemas

# PyYAML: SafeLoader uses a schema close to Failsafe
# but with int/float/bool/null inference.

JSON Schema

The JSON schema matches JSON's type system exactly: str, int, float, bool, null, map, seq. It's stricter than the Core schema — no octal/binary literals, no underscore separators, only true/false as booleans, no date inference. Use it for JSON interop or strict type behavior.

yaml
# JSON schema — same type system as JSON
# Recognizes: str, int, float, bool, null, map, seq

# Under JSON schema:
int: 42             # int
float: 3.14         # float
str: hello          # str
bool: true          # bool
null_val: null      # null
list: [1, 2, 3]     # seq
map: {a: 1}         # map

# Differences from Core schema:
# - Stricter number parsing (no 0o octal, no _ separators)
# - Booleans are only true/false (no yes/no)
# - No date/timestamp inference

# When to use JSON schema:
# - Interop with JSON tools
# - Strict type behavior
# - When you want JSON's type semantics

Core Schema

The Core schema is the YAML 1.2 default. It extends JSON with lenient parsing: hex (0x), octal (0o), binary (0b), underscores in numbers, .inf/.nan floats. Booleans are only true/false (not yes/no). Dates are strings (no inference). Most modern parsers default to Core or a 1.1-compatible variant.

yaml
# Core schema — the DEFAULT for YAML 1.2
# Extends JSON schema with more lenient scalar resolution

# Under Core schema:
int: 42             # int
neg: -17            # int
hex: 0xFF           # int (255)
oct: 0o17           # int (15)  [YAML 1.2 syntax]
bin: 0b1010         # int (10)
float: 3.14         # float
exp: 1.0e+3         # float
inf: .inf           # float
nan: .nan           # float
bool: true          # bool (only true/false, not yes/no)
null: null          # null (also ~, empty)
str: hello          # str
date: 2024-01-01    # str (Core does NOT infer dates)

# Differences from YAML 1.1:
# - yes/no/on/off are STRINGS (not booleans)
# - 017 is a string (octal is 0o17 in 1.2)
# - Dates are strings (no implicit inference)

# Most modern parsers default to Core or close to it.

YAML 1.1 vs 1.2

YAML 1.1 (PyYAML default) treats yes/no/on/off as booleans and leading-0 numbers as octal — famous sources of bugs (Norway 'no' → false). YAML 1.2 (current spec) restricts booleans to true/false and uses 0o for octal. Always check your parser's default and quote ambiguous strings.

yaml
# YAML 1.1 (older, used by PyYAML by default)
bool_1_1: yes       # true (yes/no/on/off are booleans)
octal_1_1: 017      # 15 (leading 0 = octal)
sexagesimal: 1:30   # 90 (base-60!) - obscure feature

# YAML 1.2 (current spec)
bool_1_2: yes       # str "yes" (only true/false are bool)
octal_1_2: 0o17     # 15 (explicit 0o prefix)
no_sexagesimal: 1:30 # str "1:30"

# Practical impact:
# - "no" (Norway) becomes False in PyYAML (1.1)
# - "010" becomes 8 in PyYAML, "010" in 1.2
# - "on"/"off" become True/False in 1.1, strings in 1.2

# PyYAML: defaults to 1.1 behavior
# ruamel.yaml: defaults to 1.2 (with 1.1 compatibility options)
# js-yaml: defaults to 1.1 with 1.2 options
# Go yaml.v3: defaults to 1.2-ish

# Always check your parser's default schema!

Type Resolution Differences

Type resolution differs between YAML 1.1 and 1.2 parsers: yes/no/on/off, leading-zero numbers, and sexagesimal literals parse differently. Mitigate by quoting ambiguous strings, pinning the version with %YAML, using explicit tags, and testing against all target parsers.

yaml
# Same YAML, different parsers, different types:
value: yes

# PyYAML (1.1):        True (bool)
# js-yaml (1.1):       true (bool)
# ruamel (1.2):        "yes" (str)
# Go yaml.v3 (1.2):    "yes" (str)
# Strict 1.2:          "yes" (str)

value: 010

# PyYAML (1.1):        8 (int, octal)
# ruamel (1.2):        "010" (str)
# Go yaml.v3 (1.2):    "010" (str)

value: 1.0

# All:                 1.0 (float) - consistent

value: true

# All:                 true (bool) - consistent

# Mitigation strategies:
# 1. Quote strings that look like other types
# 2. Pin the YAML version with %YAML 1.2
# 3. Use explicit tags (!!) for critical values
# 4. Test against all target parsers

Choosing a Schema

Choose schema by use case: 1.2 Core for new projects (predictable), 1.1 for PyYAML interop (or quote ambiguous strings), Failsafe/JSON for untrusted input, JSON schema for JSON interop. For config files, 1.2 Core plus quoting ambiguous strings ("no", "010", etc.) is the safest combination.

yaml
# Recommendations for choosing a schema:

# 1. For NEW projects: use YAML 1.2 Core schema
#    - More predictable (no yes/no surprises)
#    - Modern parsers support it
#    - ruamel.yaml, Go yaml.v3 default to it

# 2. For INTEROP with existing PyYAML code:
#    - Either accept 1.1 semantics
#    - Or use ruamel.yaml with 1.2 + preserve 1.1 features
#    - Quote all ambiguous strings explicitly

# 3. For UNTRUSTED input: use Failsafe or JSON schema
#    - No implicit type coercion
#    - Smaller attack surface
#    - Predictable behavior

# 4. For CONFIG FILES: use 1.2 Core + quote ambiguous strings
#    - "no", "yes", "on", "off", "010", "1.0" should be quoted
#    - Document your schema choice
#    - Lint with yamllint

# 5. For JSON INTEROP: use JSON schema
#    - Same types as JSON
#    - Round-trips cleanly
13

YAML Lint & Validation

yamllint

yamllint is the standard YAML linter (Python). It catches syntax errors, indentation problems, trailing whitespace, missing document markers, and more. Use it in CI and pre-commit hooks to catch YAML issues early. Strict mode (-s) treats warnings as errors.

yaml
# yamllint is the standard YAML linter
# Install: pip install yamllint

# Lint a file
# yamllint config.yaml

# Lint with strict mode (warnings = errors)
# yamllint -s config.yaml

# Output example:
# config.yaml
#   3:4       error    mapping values are not allowed here
#   5:1       error    syntax error: found character '	' that cannot start any token
#   8:3       warning  missing document start "---"
#   12:5      warning  comment is not indented like content

# Lint multiple files
# yamllint file1.yaml file2.yaml file3.yaml

# Lint a directory (recursive)
# yamllint -d path/to/dir

# Read from stdin
# cat config.yaml | yamllint -

Linting Configuration

Configure yamllint with .yamllint.yml. Extend a profile (default or strict) then override individual rules: indentation, line length, quoting style, comments, trailing spaces, document markers. Tune to match your team's style. Run in CI to enforce consistently.

yaml
# .yamllint.yml — configuration file for yamllint
# Place in project root or ~/.config/yamllint/config

# Use a built-in profile
extends: default

# Or use a stricter profile
# extends: strict

# Override specific rules
rules:
  # Require document start marker
  document-start: disable
  # Enforce 2-space indentation
  indentation:
    spaces: 2
    indent-sequences: true
  # Max line length
  line-length:
    max: 120
    allow-non-breakable-words: true
  # Disallow trailing spaces
  trailing-spaces: enable
  # Require consistent quoting
  quoted-strings:
    quote-type: double
    required: only-when-needed
  # Disallow comments without space before #
  comments:
    require-starting-space: true
    min-spaces-from-content: 1

Common Syntax Errors

Common YAML errors: tabs for indentation (only spaces allowed), colon without following space, unquoted strings with colons (parsed as mappings), trailing commas in flow style, inconsistent indentation, and duplicate keys. A linter catches all of these automatically.

yaml
# Error: tab characters for indentation
# bad:
# 	key: value   <- tab not allowed
# Fix: use spaces only

# Error: colon without space
# bad:  key:value
# good: key: value

# Error: unquoted special characters
# bad:  url: http://example.com:8080   <- parsed as mapping
# good: url: "http://example.com:8080"

# Error: trailing comma in flow
# bad:  list: [1, 2, 3,]
# good: list: [1, 2, 3]

# Error: inconsistent indentation
# bad:
#   key1: value
#    key2: value   <- one extra space
# good:
#   key1: value
#   key2: value

# Error: duplicate keys (some parsers warn)
# bad:
#   key: first
#   key: second
# good: rename one

Tab vs Space Errors

Tabs are strictly forbidden for YAML structural indentation — the #1 cause of parse errors. Tabs inside string values (block scalars, quoted strings) are fine. Configure your editor to use spaces (expandtab) for YAML files. Detect offending lines with grep -P '\t'.

yaml
# YAML FORBIDS tabs for indentation.
# This is the #1 cause of YAML parse errors.

# Symptom: parser error like
# "found character '\t' that cannot start any token"
# or "while scanning for the next token, found tab"

# Tabs are allowed INSIDE string values:
script: |
  #!/bin/bash
  if [ -f file ]; then   # this is fine (literal content)
  \techo found           # \t inside block scalar is OK

# But tabs for STRUCTURAL indentation are forbidden:
# bad:
# services:
# \tweb:
# \t\timage: nginx

# Fix: convert tabs to spaces
# Configure your editor to use spaces for YAML files
# VS Code: "editor.insertSpaces": true, "editor.tabSize": 2
# Vim: set expandtab shiftwidth=2

# Detect tabs:
# grep -P '\t' file.yaml   (finds offending lines)

Duplicate Key Detection

Duplicate keys in a mapping are discouraged and a common bug source (silent last-wins override). yamllint detects them with the key-duplicates rule (enabled by default). Common causes: copy-paste errors and bad merge-conflict resolution. Always lint in CI to catch them early.

yaml
# Duplicate keys are technically discouraged
# but YAML 1.2 doesn't strictly forbid them.
# Behavior varies by parser (usually last-wins).

config:
  host: localhost
  port: 8080
  host: prod.example.com   # silent override!

# yamllint catches these:
#   4:3  error  duplication of key "host" in mapping

# Enable in .yamllint.yml:
# rules:
#   key-duplicates: enable

# Other tools that detect duplicates:
# - kubeval (for Kubernetes YAML)
# - yq (programmatic checks)
# - Custom schema validation (JSON Schema)

# Common cause: copy-paste errors, merge conflicts
# resolved incorrectly. Always lint in CI to catch.

Schema Validation

Validate YAML structure with JSON Schema (works because YAML is a JSON superset). For Kubernetes, use kubeval or kubectl --dry-run. For general config, use yamllint (syntax/style) plus jsonschema (structure) or Cue (schema language). Wire validation into pre-commit hooks and CI.

yaml
# Validate YAML against a JSON Schema
# (JSON Schema works on YAML since YAML is a JSON superset)

# Python example with jsonschema + PyYAML:
# import json, yaml, jsonschema
# with open('schema.json') as f: schema = json.load(f)
# with open('config.yaml') as f: doc = yaml.safe_load(f)
# jsonschema.validate(doc, schema)

# Tools for Kubernetes YAML:
# kubeval            - validate against K8s schemas
# kubectl apply --dry-run=client -f file.yaml
# conftest           - policy-based validation (OPA)

# Tools for general YAML:
# yamllint           - syntax + style
# jsonschema-cli     - JSON Schema validation
# cue                - schema + validation language
# dataladen          - config validation

# Pre-commit hook example (.pre-commit-config.yaml):
# repos:
#   - repo: https://github.com/adrienverge/yamllint
#     rev: v1.32.0
#     hooks:
#       - id: yamllint
#         args: [-s]
14

Docker Compose YAML

Service Definition

Docker Compose files define multi-container apps. The top-level keys are version, services, volumes, networks, configs, secrets. Each service defines image/build, ports, volumes, environment, depends_on, restart policy. Volumes declared at top level are named and persisted.

yaml
# docker-compose.yml — multi-container apps
version: "3.9"   # compose file format version

services:
  web:
    image: nginx:1.25
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./html:/usr/share/nginx/html:ro
    restart: unless-stopped
    depends_on:
      - api

  api:
    build:
      context: ./api
      dockerfile: Dockerfile
    environment:
      - NODE_ENV=production
      - DB_URL=postgres://db:5432/app
    ports:
      - "8080:8080"
    restart: always

  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
    volumes:
      - db_data:/var/lib/postgresql/data
    restart: always

volumes:
  db_data:

Volumes & Networks

Compose volumes are bind mounts (host:container), named (declared in top-level volumes), or anonymous. The :ro suffix makes mounts read-only. Networks isolate services; internal: true blocks external access. Named volumes persist data across container recreations.

yaml
services:
  web:
    image: nginx
    volumes:
      # Bind mount: host:container
      - ./html:/usr/share/nginx/html:ro
      # Named volume: name:container
      - shared_data:/data
      # Anonymous volume
      - /tmp/cache
    networks:
      - frontend
      - backend

  db:
    image: postgres
    volumes:
      - db_data:/var/lib/postgresql/data
    networks:
      - backend

volumes:
  db_data:
    driver: local
  shared_data:
    driver: nfs
    driver_opts:
      type: nfs
      device: ":/path/to/share"

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true   # no external access

Environment Variables

Compose supports three env-var styles: list (KEY=value), map (KEY: value), and env_file. ${VAR} is interpolated from the host shell or .env file at parse time; ${VAR:-default} provides defaults. Use $$ to escape a literal $. The .env file in the project root is auto-loaded.

yaml
services:
  api:
    image: myapi
    # Method 1: list of KEY=value
    environment:
      - NODE_ENV=production
      - LOG_LEVEL=info
    # Method 2: map (cleaner)
    environment:
      NODE_ENV: production
      LOG_LEVEL: info
    # Method 3: file
    env_file:
      - .env
      - .env.production
    # Interpolation from host environment
    environment:
      - API_KEY=${API_KEY}     # from shell
      - PORT=${PORT:-8080}      # default 8080

# .env file (auto-loaded by docker compose):
# API_KEY=abc123
# PORT=8080
# DB_URL=postgres://...

# Compose substitutes ${VAR} from host shell or .env
# Use $$ to escape (literal $):
# - TOKEN=price_$$5   # produces price_$5

Depends, Healthchecks & Restart

depends_on with condition: service_healthy waits for a dependency's healthcheck to pass before starting. Healthchecks use test (CMD or CMD-SHELL), interval, timeout, retries, start_period. Restart policies: no, always, unless-stopped (survives reboots but respects manual stop), on-failure.

yaml
services:
  api:
    image: myapi
    depends_on:
      db:
        condition: service_healthy   # wait for db healthy
      redis:
        condition: service_started
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s

  db:
    image: postgres
    restart: always
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

# restart policies:
# no              - never restart (default)
# always          - always restart on stop
# unless-stopped  - restart unless manually stopped
# on-failure      - restart only on non-zero exit

Override Files

Compose merges override files on top of the base. docker-compose.override.yml is auto-applied. Mappings are deep-merged (overrides win); sequences are usually replaced (not appended) — watch out for ports/volumes. Use -f flags to specify explicit file order for environment-specific configs.

yaml
# docker-compose.yml — base
services:
  web:
    image: nginx
    ports:
      - "80:80"
    environment:
      - ENV=base

# docker-compose.override.yml — auto-applied on top
# (loaded automatically by 'docker compose up')
services:
  web:
    ports:
      - "8080:80"      # overrides "80:80"
    environment:
      - DEBUG=true     # added
      - ENV=override   # overrides ENV=base
    volumes:
      - ./dev.conf:/etc/nginx/conf.d/default.conf

# Multiple override files (-f flag):
# docker compose -f compose.yml -f compose.prod.yml -f compose.secrets.yml up

# Merge rules:
# - mappings: deep-merged (overrides win, others kept)
# - sequences: usually REPLACED (not appended) —
#   ports/depends_on/volumes are exceptions in some versions

# Use -f to specify explicit file order

Profiles & Compose Features

Profiles let you opt-in to services (e.g. debug, test, ci) with --profile flag. Other v3 features: init (proper PID 1 signal handling), extends (inherit from another service), configs and secrets (mounted as files), deploy (Swarm placement/replicas/resources). Profiles are great for keeping one compose file for multiple environments.

yaml
# Profiles — opt-in services
services:
  web:
    image: nginx
    # no profile: always started

  debug:
    image: busybox
    profiles: ["debug"]   # only with --profile debug

  loadtest:
    image: locust
    profiles: ["test", "ci"]

# Enable a profile:
# docker compose --profile debug up
# docker compose --profile test --profile ci up

# Compose file format features (v3.9+):
# - deploy: placement, replicas, resources (Swarm-only in v3)
# - configs: external config files
# - secrets: mounted secret files
# - extends: inherit from another service
# - init: run tini as PID 1

services:
  worker:
    image: worker
    init: true   # proper signal handling
    extends:
      service: base   # inherit fields
    configs:
      - config1
    secrets:
      - api_key

configs:
  config1:
    file: ./config.toml
secrets:
  api_key:
    file: ./api_key.txt
15

Kubernetes YAML

Object Structure

Every Kubernetes object has apiVersion, kind, metadata, and spec. apiVersion is group/version (e.g. apps/v1, v1 for core). metadata includes name, namespace, labels, annotations. spec is the desired state — Kubernetes works to make reality match spec. status is set by the system.

yaml
# Every Kubernetes object has the same 4 top-level fields
apiVersion: apps/v1        # API group + version
kind: Deployment           # object type
metadata:                  # identity
  name: my-app
  namespace: production
  labels:
    app: my-app
    tier: backend
  annotations:
    deployment.kubernetes.io/revision: "1"
spec:                      # desired state
  replicas: 3
  selector:
    matchLabels:
      app: my-app
  template:
    metadata:
      labels:
        app: my-app
    spec:
      containers:
        - name: app
          image: my-app:1.0.0
          ports:
            - containerPort: 8080

# apiVersion + kind + metadata + spec is the universal shape
# (some objects use status too, but you don't set it)

Labels & Selectors

Labels identify and group objects; selectors query by labels. Equality-based (key=value) and set-based (In, NotIn, Exists, DoesNotExist) selectors are both supported. Annotations hold non-identifying metadata (descriptions, contact info, tooling config). Labels are for selection; annotations are for additional info.

yaml
# Labels — identify and group objects
metadata:
  labels:
    app: my-app
    tier: backend
    env: production
    version: "1.0"

# Selectors — query objects by labels
# Equality-based
selector:
  matchLabels:
    app: my-app
    env: production

# Set-based
selector:
  matchExpressions:
    - {key: tier, operator: In, values: [backend, api]}
    - {key: env, operator: NotIn, values: [dev]}
    - {key: version, operator: Exists}

# kubectl commands:
# kubectl get pods -l app=my-app
# kubectl get pods -l 'tier in (backend,api),env!=dev'
# kubectl get pods -l app=my-app -l env=production

# Annotations — non-identifying metadata
metadata:
  annotations:
    description: "My app deployment"
    contact: "[email protected]"

Pod Spec

A Pod spec defines containers, their images, ports, env vars (literal or from secrets/configmaps), resources (requests/limits), probes (readiness/liveness), volumes and volumeMounts. Pods are the smallest deployable unit but you usually create them via Deployments, not directly.

yaml
apiVersion: v1
kind: Pod
metadata:
  name: my-pod
spec:
  containers:
    - name: app
      image: my-app:1.0
      ports:
        - containerPort: 8080
          name: http
      env:
        - name: LOG_LEVEL
          value: info
        - name: SECRET_KEY
          valueFrom:
            secretKeyRef:
              name: app-secrets
              key: secret-key
      resources:
        requests: {cpu: 100m, memory: 128Mi}
        limits: {cpu: 500m, memory: 512Mi}
      readinessProbe:
        httpGet: {path: /health, port: 8080}
        initialDelaySeconds: 5
        periodSeconds: 10
      volumeMounts:
        - name: config
          mountPath: /etc/config
  volumes:
    - name: config
      configMap:
        name: app-config
  restartPolicy: Always

Deployment

A Deployment manages ReplicaSets and Pods. spec.replicas sets the desired count. strategy.type can be RollingUpdate (default, controlled by maxSurge/maxUnavailable) or Recreate. The pod template (spec.template) defines how pods are created. Manage rollouts with kubectl rollout status/undo/history.

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  selector:
    matchLabels:
      app: my-app
  template:
    metadata:
      labels:
        app: my-app
    spec:
      containers:
        - name: app
          image: my-app:1.0.0
          ports:
            - containerPort: 8080
          resources:
            requests: {cpu: 100m, memory: 128Mi}
            limits: {cpu: 500m, memory: 512Mi}
          readinessProbe:
            httpGet: {path: /ready, port: 8080}
  # Rollback / history
  # kubectl rollout status deployment/my-app
  # kubectl rollout undo deployment/my-app
  # kubectl rollout history deployment/my-app

Service

A Service exposes Pods as a network service. type ClusterIP (default) is internal-only; NodePort exposes on each node; LoadBalancer provisions a cloud LB; ExternalName is a DNS CNAME. selector routes traffic to matching pods. ports map service port to container targetPort (number or named).

yaml
apiVersion: v1
kind: Service
metadata:
  name: my-app
spec:
  type: ClusterIP      # ClusterIP (default), NodePort, LoadBalancer, ExternalName
  selector:
    app: my-app        # routes to pods with these labels
  ports:
    - name: http
      port: 80         # service port
      targetPort: 8080 # container port (or named port)
      protocol: TCP
    - name: https
      port: 443
      targetPort: 8443

# NodePort: exposes on each node's IP at a static port (30000-32767)
# LoadBalancer: provisions a cloud LB
# ExternalName: CNAME to an external service

# Service types:
# ClusterIP    - internal cluster IP (default)
# NodePort     - exposes on node port + ClusterIP
# LoadBalancer - cloud LB + NodePort + ClusterIP
# ExternalName - DNS CNAME (no proxying)

ConfigMap & Secret

ConfigMaps hold non-sensitive config (strings or files); Secrets hold sensitive data (base64-encoded in data, or plain in stringData). Both are consumed via env vars (valueFrom) or mounted as volumes. Secrets are NOT encrypted by default — enable encryption at rest and restrict RBAC access.

yaml
# ConfigMap — non-sensitive configuration
apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  log_level: info
  max_connections: "100"
  config.yaml: |
    server:
      host: 0.0.0.0
      port: 8080
---
# Secret — sensitive data (base64-encoded)
apiVersion: v1
kind: Secret
metadata:
  name: app-secrets
type: Opaque
data:
  # base64-encoded values
  api_key: c2VjcmV0LWtleS0xMjM=   # echo -n 'secret-key-123' | base64
  db_password: cGFzc3dvcmQ=
# Or use stringData for plain text (auto-encoded):
# stringData:
#   api_key: secret-key-123

# Consumed by pods:
# env:
#   - name: API_KEY
#     valueFrom:
#       secretKeyRef:
#         name: app-secrets
#         key: api_key
# volumes:
#   - name: config
#     configMap:
#       name: app-config
16

CI/CD Configuration (GitHub Actions & GitLab CI)

GitHub Actions Workflow

GitHub Actions workflows live in .github/workflows/. on: triggers (push, PR, schedule, workflow_dispatch). jobs contain runs-on + steps. uses: references actions, run: executes shell. Expressions use ${{ }} syntax — must be escaped as \${{ }} inside YAML template literals.

yaml
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm test
      - run: npm run build
        env:
          NODE_ENV: production

  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm run lint

# ${{ }} is GitHub Actions expression syntax
# MUST be escaped as ${{ }} in YAML template literals
# (here we use literal text, no escaping needed)

GitLab CI Pipeline

GitLab CI lives in .gitlab-ci.yml. stages define the pipeline order. jobs have stage, image, script, and many options (only/except, rules, when, artifacts, environment). Variables use $VAR syntax (no escaping needed in YAML block scalars). when: manual requires a click to deploy.

yaml
# .gitlab-ci.yml
stages:
  - test
  - build
  - deploy

variables:
  NODE_ENV: production
  IMAGE_TAG: $CI_COMMIT_SHORT_SHA

test:
  stage: test
  image: node:20
  script:
    - npm ci
    - npm test
  artifacts:
    reports:
      junit: test-results.xml
  coverage: '/Lines.*:\s(\d+\.\d+)\%/'

build:
  stage: build
  image: docker:24
  services:
    - docker:24-dind
  script:
    - docker build -t myapp:$IMAGE_TAG .
    - docker push myapp:$IMAGE_TAG
  only:
    - main

deploy:production:
  stage: deploy
  script:
    - ./deploy.sh
  environment:
    name: production
  only:
    - main
  when: manual   # require manual trigger

Matrix Builds

Matrix builds run a job multiple times with different variables. GitHub Actions uses strategy.matrix with include/exclude. GitLab uses parallel.matrix. fail-fast: false prevents one failure from cancelling siblings. Useful for cross-platform, multi-version testing. The ${{ }} syntax must be escaped in YAML literals.

yaml
# GitHub Actions matrix
jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        node: [18, 20, 22]
        exclude:
          - os: windows-latest
            node: 18
        include:
          - os: ubuntu-latest
            node: 20
            experimental: true
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
      - run: npm ci
      - run: npm test

# GitLab CI matrix (parallel matrix):
# test:
#   image: node:${NODE_VERSION}
#   parallel:
#     matrix:
#       - NODE_VERSION: [18, 20, 22]
#         OS: [linux, macos]

Reusable Workflows & Includes

Both systems support reuse. GitHub Actions uses reusable workflows (workflow_call trigger) invoked with uses:. GitLab uses include (project, local, template, remote). Reuse reduces duplication for shared CI logic. Pass inputs/secrets explicitly to keep workflows composable and auditable.

yaml
# GitHub Actions: reusable workflow
# .github/workflows/reusable.yml
on:
  workflow_call:
    inputs:
      node-version:
        required: true
        type: string
    secrets:
      token:
        required: true
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
      - run: npm ci
        env:
          TOKEN: ${{ secrets.token }}

# Caller:
# jobs:
#   build:
#     uses: ./.github/workflows/reusable.yml
#     with:
#       node-version: '20'
#     secrets:
#       token: ${{ secrets.MY_TOKEN }}

# GitLab CI: include
# include:
#   - project: 'ci/templates'
#     file: '/node.yml'
#   - local: '/.gitlab/extra.yml'
#   - template: Jobs/SAST.gitlab-ci.yml

Anchors in CI Configs

YAML anchors reduce duplication in CI configs. GitLab CI uses them heavily: define a hidden job (.template) with &anchor, then <<: *anchor to merge. GitHub Actions supports anchors but discourages them across jobs (prefer reusable workflows). Anchors make configs DRY but harder to trace — use judiciously.

yaml
# YAML anchors reduce duplication in CI configs
# GitLab CI commonly uses anchors

.default_template: &default_template
  image: node:20
  before_script:
    - npm ci
  cache:
    paths:
      - node_modules/

test:
  <<: *default_template
  script:
    - npm test

lint:
  <<: *default_template
  script:
    - npm run lint

build:
  <<: *default_template
  script:
    - npm run build
  artifacts:
    paths:
      - dist/

# GitHub Actions supports anchors too (since 2022):
# jobs:
#   base: &base
#     runs-on: ubuntu-latest
#     steps:
#       - uses: actions/checkout@v4
#   test:
#     uses: ./.github/workflows/reusable.yml
#   # Anchors across jobs are limited — prefer reusable workflows

Environments & Secrets

GitHub Actions environments gate deployments (require approval, restrict to branches) and hold environment-scoped secrets. Secrets are referenced via ${{ secrets.NAME }}. GitLab uses protected (branch-restricted) and masked (log-hidden) variables defined in the UI. Never log secrets — they may leak in error output.

yaml
# GitHub Actions: environments & secrets
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment:
      name: production        # requires approval if configured
      url: https://app.example.com
    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        run: ./deploy.sh
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
          API_TOKEN: ${{ secrets.API_TOKEN }}
          ENV: production

# Secret scopes:
# - Repository secrets: available to all workflows
# - Environment secrets: only when job uses that environment
# - Organization secrets: shared across repos

# GitLab CI: protected/masked variables
# variables:
#   # defined in UI: Settings > CI/CD > Variables
#   # - Protected: only on protected branches/tags
#   # - Masked: hidden in job logs
# deploy:
#   script:
#     - ./deploy.sh
#   environment:
#     name: production
#   only:
#     - main
17

Security Considerations

Deserialization Risks

YAML deserialization is dangerous because tags like !!python/object/apply can call arbitrary functions. PyYAML's yaml.load() was unsafe by default before 5.1 (code execution from untrusted input). Always use yaml.safe_load() for untrusted input — it rejects object-instantiation tags. Other languages have similar dangers (SnakeYAML, Psych).

yaml
# YAML deserialization can be DANGEROUS.
# Some loaders let YAML instantiate arbitrary objects.

# DANGEROUS (PyYAML < 5.1, default was unsafe):
# data = yaml.load(untrusted_input)
# An attacker can craft YAML that runs arbitrary code:
# !!python/object/apply:os.system ["rm -rf /"]

# Other dangerous tags:
# !!python/object/apply:...     # call any Python function
# !!python/object/new:...       # instantiate any class
# !ruby/object:...               # Ruby object instantiation
# !java/object:...               # Java (SnakeYAML)

# Safe loaders reject these tags by default.
# Modern PyYAML (>= 5.1): yaml.load() requires a SafeLoader
# yaml.safe_load() is ALWAYS safe.

# Rule of thumb:
# - NEVER use yaml.load() on untrusted input
# - ALWAYS use yaml.safe_load() (or full_load on trusted files)
# - Or use a schema-validated loader

safe_load vs load

Always use safe_load (Python), safe_load (Ruby Psych), or default safe schemas (js-yaml, Go yaml.v3). PyYAML 5.1+ requires explicit Loader for yaml.load(). full_load allows standard tags but not arbitrary objects — use only on trusted files. Safe loaders reject all object-instantiation tags.

yaml
# Python (PyYAML) — the classic safe/unsafe distinction

# SAFE: safe_load rejects all custom tags
import yaml
data = yaml.safe_load(untrusted_yaml)   # ALWAYS use this

# SAFE: load with SafeLoader (explicit)
data = yaml.load(untrusted_yaml, Loader=yaml.SafeLoader)

# FULL (trusted files only): full_load allows standard tags
# but not arbitrary python objects
data = yaml.full_load(trusted_yaml)

# UNSAFE: UnsafeLoader allows !!python/object — DANGEROUS
data = yaml.load(untrusted_yaml, Loader=yaml.UnsafeLoader)  # NEVER on untrusted

# PyYAML 5.1+ requires Loader argument for yaml.load()
# (raises error without it) — safety improvement.

# Other languages:
# JS (js-yaml): yaml.load(str) uses DEFAULT_SAFE_SCHEMA
# Go (yaml.v3): no object instantiation (safe by design)
# Ruby (Psych): Psych.safe_load is the safe variant
# Java (SnakeYAML): SafeConstructor for safe loading

Local Tag Exploits

Custom tags (!include, !env, !file, !exec, !template) can be exploited even without !!python/object. A !include that reads files can be tricked into reading /etc/passwd or .ssh/id_rsa via path traversal. Audit every custom constructor: validate paths against allowlists, refuse absolute paths and ../, sandbox the loader.

yaml
# Even non-builtin tags can be exploited if your loader
# registers constructors that have side effects.

# Example: a "harmless" custom tag
# yaml.add_constructor('!include', include_file)
#
# If !include reads files from disk, an attacker can
# craft YAML to read sensitive files:
# config: !include /etc/passwd
# or:
# config: !include /etc/shadow
# or:
# config: !include ../../../.ssh/id_rsa

# Mitigation:
# 1. Validate the included path against an allowlist
# 2. Restrict !include to a specific directory
# 3. Refuse absolute paths and ../ traversal
# 4. Run the loader in a sandbox

# Other "innocent" tags that can be exploited:
# !env VAR_NAME        -> leaks env vars
# !exec "command"      -> arbitrary command execution
# !file /path          -> file disclosure
# !template <% ... %>  -> code injection

# Audit every custom constructor for side effects.

Billion Laughs & DoS

The Billion Laughs attack uses nested aliases to expand a tiny document into gigabytes of memory (10 aliases per level, 5 levels = 10^9 references). Mitigate with alias depth limits, total size limits, streaming parsers, and rejecting excessive nesting. Also watch for deep nesting (stack overflow) and huge flat collections.

yaml
# "Billion Laughs" attack — exponential expansion via aliases
a: &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol","lol"]
b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a,*a]
c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b,*b]
d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c,*c]
e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d,*d]

# This 5-line document expands to ~10^9 string references
# consuming gigabytes of memory on parse.

# Mitigations:
# 1. Limit alias depth (some parsers do this)
# 2. Limit total parsed size / memory
# 3. Use a streaming parser for untrusted input
# 4. PyYAML has a limit but not all parsers do
# 5. Reject documents with excessive alias nesting

# Other DoS vectors:
# - Very deep nesting (parser stack overflow)
# - Huge flat sequences/mappings (memory)
# - Pathological number formats (regex backtracking)
# - Quadratic blowup via duplicate keys

# For untrusted input: set hard size + depth limits.

Input Validation

Layered validation: (1) size limit, (2) safe_load to reject dangerous tags, (3) type check (expect a dict, not a list), (4) schema validation (JSON Schema, Pydantic, Cue), (5) domain validation (business rules like port range). Each layer catches different attacks. Never trust structure — always validate before use.

yaml
# Layered defense for untrusted YAML input:

# 1. Size limit (reject oversized input)
MAX_SIZE = 1024 * 1024  # 1 MB
if len(input_text) > MAX_SIZE:
    raise ValueError("input too large")

# 2. Safe parser (rejects dangerous tags)
import yaml
try:
    data = yaml.safe_load(input_text)
except yaml.YAMLError as e:
    raise ValueError(f"invalid YAML: {e}")

# 3. Type check (expect a mapping, not a list)
if not isinstance(data, dict):
    raise ValueError("expected a mapping at top level")

# 4. Schema validation (JSON Schema, Pydantic, etc.)
from pydantic import BaseModel, ValidationError
class Config(BaseModel):
    host: str
    port: int = 8080
try:
    config = Config(**data)
except ValidationError as e:
    raise ValueError(f"schema validation failed: {e}")

# 5. Domain validation (business rules)
if config.port < 1 or config.port > 65535:
    raise ValueError("port out of range")

Library Recommendations

Pick safe-by-default libraries: PyYAML (safe_load), ruamel.yaml, js-yaml (default safe), Go yaml.v3, Psych.safe_load, SnakeYAML with SafeConstructor, serde_yaml. Avoid yaml.load without explicit safe loader. Pin to recent versions for security patches. Audit custom constructors. Read the library's security docs.

yaml
# Safe-by-default YAML libraries by language:

# Python:
#   - PyYAML >= 5.1 (yaml.safe_load)
#   - ruamel.yaml (safe by default, round-trip mode)
#   AVOID: yaml.load without explicit SafeLoader

# JavaScript/TypeScript:
#   - js-yaml (yaml.load uses DEFAULT_SAFE_SCHEMA)
#   AVOID: yaml.load with DEFAULT_FULL_SCHEMA on untrusted

# Go:
#   - gopkg.in/yaml.v3 (no object instantiation, safe)
#   AVOID: yaml.v2 has subtle differences, prefer v3

# Ruby:
#   - Psych (Psych.safe_load)
#   AVOID: YAML.load (uses unsafe loader in old Ruby)

# Java:
#   - SnakeYAML with SafeConstructor
#   AVOID: new Yaml() without SafeConstructor on untrusted
#   (SnakeYAML had CVEs from unsafe loading)

# Rust:
#   - serde_yaml (safe by design, no arbitrary types)

# General rule:
# - Read the library's security docs
# - Pin to a recent version (security patches)
# - Use safe_load / SafeConstructor / safe schema
# - Audit custom constructors for side effects
18

Best Practices

Consistent Indentation

Use 2 spaces consistently (the de facto standard), never tabs. Configure your editor to insert spaces for YAML. Use formatters (prettier, yamlfixer) and linters (yamllint) to enforce consistency. Inconsistent indentation is the most common YAML error and breaks parsing.

yaml
# Use 2 spaces for indentation (most common)
# NEVER use tabs (YAML forbids them)

# GOOD
server:
  host: localhost
  port: 8080
  routes:
    - path: /api
      handler: apiHandler
    - path: /web
      handler: webHandler

# BAD (inconsistent)
# server:
#    host: localhost
#   port: 8080      # wrong indent

# Configure your editor:
# VS Code: "[yaml]": {"editor.insertSpaces": true, "editor.tabSize": 2}
# Vim:     autocmd FileType yaml setlocal ts=2 sts=2 sw=2 expandtab
# Emacs:   (add-hook 'yaml-mode-hook
#           (lambda () (setq yaml-indent-offset 2)))

# Use a formatter:
# prettier (with yaml plugin) normalizes indentation
# yamllint checks it
# yamlfixer auto-fixes issues

Quoting Strategy

Quote strings that look like numbers, booleans, null, or dates; that contain colons/commas/special chars; or that start with indicators (- ? : * & ! | > ' " % @). Otherwise prefer plain (unquoted) for readability. Use double quotes for escape sequences (\n, \t), single for literal strings with apostrophes.

yaml
# Quote strings when:
# 1. They look like another type (number, bool, null, date)
version: "1.0"        # string, not float
answer: "yes"         # string, not bool (1.1)
region: "no"          # string "no", not False (1.1)
date: "2024-01-01"    # string, not date
phone: "555-1234"     # string, not number

# 2. They contain special characters
url: "https://example.com:8080"   # colon
list: "a, b, c"                    # comma
expr: "value:with:colons"          # colons

# 3. They start with special indicators (-, ?, :, *, &, !, |, >, ', ", %, @, `)
flag: "-not-a-list"
key: "?not-a-complex-key"

# Don't quote otherwise — plain is more readable
name: Alice            # no quotes needed
path: /usr/local/bin   # no quotes needed

# Prefer double quotes for escapes (\n, \t)
# Prefer single quotes for literal strings with apostrophes
greeting: "Hello\nWorld"
possessive: 'Alice''s car'

Avoid Tabs & Other Pitfalls

Avoid common pitfalls: tabs (forbidden — use spaces), unquoted yes/no/on/off (1.1 vs 1.2 ambiguity), unquoted strings with colons, trailing whitespace, CRLF line endings (use LF), BOM (save as UTF-8 without BOM), and very long lines (wrap with block scalars). A linter catches most of these.

yaml
# Pitfall 1: tabs for indentation (FORBIDDEN)
# Configure editor to use spaces

# Pitfall 2: yes/no/on/off as strings (1.1 vs 1.2)
# Always quote: "yes", "no", "on", "off"

# Pitfall 3: unquoted strings with colons
# bad:  url: http://x:8080
# good: url: "http://x:8080"

# Pitfall 4: trailing whitespace
# Some parsers are strict; linters always flag it
key: value   # <- no trailing spaces

# Pitfall 5: Windows line endings (CRLF)
# Use LF (\n) only — CRLF can cause subtle issues
# .gitattributes: *.yaml text eol=lf

# Pitfall 6: BOM at start of file
# Save as UTF-8 without BOM

# Pitfall 7: very long lines
# Wrap with block scalars or flow style
description: >
  This is a long description that wraps
  across multiple lines for readability.

# Pitfall 8: inconsistent key ordering
# Group related keys, be consistent

Version Pinning & Schema Docs

Pin the YAML version with %YAML 1.2 if 1.1 vs 1.2 differences matter. Document your schema: required/optional fields, types, defaults, ranges — in comments, a JSON Schema file, or README. Pin schema versions in code so config validation is reproducible. This reduces onboarding friction and config errors.

yaml
# Pin the YAML version if it matters
%YAML 1.2
---
key: value

# Document your schema expectations in comments:
# Required fields:
#   - host: string (hostname or IP)
#   - port: integer (1-65535)
# Optional fields:
#   - tls: boolean (default: false)
#   - cert: string (path, required if tls: true)

config:
  host: example.com
  port: 443
  tls: true
  cert: /etc/ssl/cert.pem

# Or use a JSON Schema file (config.schema.json)
# and reference it from your docs / CI:
# "Validated against schema v1.2; see config.schema.json"

# For libraries, document the schema in the README:
# ## Configuration
# The config.yaml file must contain:
# - host (str, required)
# - port (int, required, 1-65535)
# - tls (bool, optional, default: false)

# Pin schema versions in code:
# CONFIG_SCHEMA_VERSION = "1.2"
# validate(data, schema_v1_2)

Linting in CI

Wire YAML linting into CI (GitHub Actions, GitLab CI) and pre-commit hooks. Use yamllint for syntax/style and prettier for formatting. Editor integration (VS Code YAML extension, yaml-language-server) provides inline linting and schema validation. Catching YAML errors in CI is far cheaper than in production.

yaml
# .github/workflows/lint.yml
name: Lint YAML
on: [push, pull_request]
jobs:
  yamllint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install yamllint
        run: pip install yamllint
      - name: Lint
        run: yamllint -s .

# GitLab CI:
# yamllint:
#   image: python:3.12
#   script:
#     - pip install yamllint
#     - yamllint -s .

# Pre-commit hook (.pre-commit-config.yaml):
# repos:
#   - repo: https://github.com/adrienverge/yamllint
#     rev: v1.35.1
#     hooks:
#       - id: yamllint
#         args: [-s, --config-file, .yamllint.yml]
#   - repo: https://github.com/pre-commit/mirrors-prettier
#     rev: v3.1.0
#     hooks:
#       - id: prettier
#         types: [yaml]

# Editor integration:
# VS Code: install "YAML" extension (redhat)
#   + "yamllint" for inline linting
#   + schema validation via yaml-language-server

Structure & Readability

Group related keys with comment headers. Keep nesting shallow (3-4 levels max) — deeply nested YAML is hard to read. Use anchors for genuinely reused values. Use block scalars for multi-line strings. Keep lines under 120 characters. Readability matters because humans edit YAML.

yaml
# Group related keys; use comments to delineate sections
server:
  # Networking
  host: 0.0.0.0
  port: 8080
  tls:
    enabled: true
    cert: /etc/ssl/cert.pem

  # Limits
  timeout: 30
  max_connections: 100
  retries: 3

# Keep nesting shallow (3-4 levels max)
# Deeply nested YAML is hard to read and edit.

# If a value is reused, use an anchor:
defaults: &defaults
  timeout: 30
  retries: 3

prod:
  <<: *defaults
  env: production

staging:
  <<: *defaults
  env: staging

# Use block scalars for multi-line values
script: |
  #!/bin/bash
  set -e
  echo "deploying"

# Keep lines under 120 chars; wrap with block scalars or
# multi-line flow style.
19

Advanced Features

Complex Mapping Keys

The ? indicator marks a complex key (a mapping or sequence used as a key). Useful for lookup tables (e.g. pricing by region+instance). Caveat: many serializers don't preserve complex keys and may stringify them on output. Most config files stick to plain string keys for simplicity and tool compatibility.

yaml
# Use ? to mark a complex (non-scalar) key
? apple
: red fruit

? orange
: orange fruit

# A mapping as a key
? host: localhost
  port: 8080
: connection_string

# A sequence as a key
? [us-east-1, t2.micro]
: 0.0116
? [us-east-1, t2.small]
: 0.023
? [us-west-2, t2.micro]
: 0.0125

# Useful for lookup tables
pricing:
  ? [production, large]
  : 100
  ? [production, small]
  : 50
  ? [development, large]
  : 20

# In flow style:
{[a, b]: value, {x: 1}: other}

# Caveat: many serializers don't preserve complex keys
# and may convert them to strings on output.

Sets (!!set)

A YAML set (!!set) is a mapping where keys are the elements and values are null. Some parsers map it to a native Set type. Use it to declare unique collections (roles, permissions). Caveat: JSON has no Set type, so JSON conversion produces an object with null values. Not all serializers preserve the !!set tag.

yaml
# A set is a mapping with null values
# Use the !!set tag for clarity
permissions: !!set
  ? read
  ? write
  ? execute

# Equivalent to:
permissions:
  read: null
  write: null
  execute: null

# Or in flow style:
flags: !!set { red, green, blue }

# Parsers may map this to a native Set type:
# Python: set() (PyYAML with !!set constructor)
# Ruby: Set (Psych)
# JavaScript: depends on library

# Use case: declare a unique collection
roles: !!set
  ? admin
  ? editor
  ? viewer

# Caveat: JSON has no Set type, so JSON conversion
# usually produces an object with null values.

Ordered Mappings (!!omap)

!!omap (ordered mapping) is a sequence of single-key mappings that preserves insertion order. Useful for timelines, ordered preferences, or where order matters semantically. Modern parsers preserve regular mapping order anyway (Python dict 3.7+, Ruby Hash), but Go maps are random — !!omap makes order explicit there.

yaml
# An ordered mapping preserves key order
# Use !!omap tag
timeline: !!omap
  - 2024-01-01: event A
  - 2024-02-01: event B
  - 2024-03-01: event C

# Equivalent representation as a list of single-key mappings:
# timeline:
#   - 2024-01-01: event A
#   - 2024-02-01: event B
#   - 2024-03-01: event C

# Parsers may map this to:
# Python: list of tuples, or OrderedDict
# Ruby: Array of one-key hashes (or OrderedDict)
# Go: ordered map (with custom type)

# Use case: ordered key-value pairs where order matters
# - event timelines
# - ordered preferences
# - step-by-step configurations

# Caveat: regular YAML mappings DO preserve order in
# most modern parsers (Python dict 3.7+, Ruby Hash,
# Go map iteration is random though). !!omap makes the
# intent explicit and works even where maps are unordered.

Binary Data (!!binary)

!!binary encodes binary data as base64 in YAML. Parsers decode it to native bytes (Python bytes, Ruby ASCII-8BIT string). Useful for embedding small images or icons directly. For large binary data, prefer referencing by path or URL. Multi-line base64 works with folded (>) or literal (|) block scalars.

yaml
# Encode binary data as base64 with !!binary tag
image: !!binary "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9
  awAAAABJRU5ErkJggg=="

# Multi-line base64 (folded or literal works)
data: !!binary |
  iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42m
  k+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==

# Parsers decode to bytes:
# Python: bytes object
# Ruby: ASCII-8BIT string
# JavaScript: depends on library (often base64 string)

# Use case: embed small images, icons, or other binary
# directly in YAML config

# For larger binary data, prefer:
# - Reference by path:  image: /path/to/image.png
# - Reference by URL:   image: https://example.com/img.png
# - Store separately and link

# Verify with:
# python -c 'import yaml; d=yaml.safe_load(open("f.yaml")); print(len(d["image"]))'

Custom Constructors

Register custom constructors to map tags (!money) to native objects. Use add_constructor with a SafeLoader-scoped loader. Constructors receive the loader and node; use construct_scalar/mapping/sequence to read the node value. Always audit constructors for side effects (file reads, subprocess calls) — they can be exploited via untrusted input.

yaml
# PyYAML: register a constructor for a custom tag
import yaml

class Money:
    def __init__(self, currency, amount):
        self.currency = currency
        self.amount = amount
    def __repr__(self):
        return f"Money({self.currency} {self.amount})"

def money_constructor(loader, node):
    value = loader.construct_scalar(node)  # "USD 19.99"
    currency, amount = value.split()
    return Money(currency, float(amount))

# Register the constructor (use SafeLoader scope)
yaml.add_constructor('!money', money_constructor, Loader=yaml.SafeLoader)

# Now this YAML parses to a Money object:
# price: !money "USD 19.99"
config = yaml.safe_load("price: !money \"USD 19.99\"")
print(config['price'])  # Money(USD 19.99)

# Multi-argument constructors use construct_mapping or
# construct_sequence depending on the node kind.

# SECURITY: audit every constructor for side effects.
# A constructor that calls open(), os.system(), or
# subprocess can be exploited via untrusted input.

YAML 1.2 Features & Future

YAML 1.2 made JSON a strict subset, restricted booleans to true/false, and adopted 0o for octal. The merge key << was removed from the core spec but is still widely supported. YAML 1.2.2 (2021) clarified the spec; 1.3 is in development. Future-proof by pinning the version, quoting ambiguous strings, and considering Cue/Dhall/Jsonnet for complex config.

yaml
# YAML 1.2 (2009, current spec) key changes from 1.1:
# - JSON is a strict subset
# - Booleans: only true/false (not yes/no/on/off)
# - Octal: 0o prefix (not leading 0)
# - Removed sexagesimal numbers
# - Removed merge key << from core spec (still widely supported)

# YAML 1.3 (in development) aims to:
# - Clarify ambiguous parts of 1.2
# - Standardize error reporting
# - Improve interoperability between parsers
# - Possibly restore some 1.1 features as optional

# YAML 1.2.2 (2021 update, spec revision):
# - Clarified type resolution
# - Better-defined core schema
# - Improved examples
# - No breaking changes

# Recommended practices for future-proofing:
# 1. Pin %YAML 1.2 in critical files
# 2. Quote ambiguous strings (yes/no, leading-zero numbers)
# 3. Avoid relying on << merge key for new formats
#    (or document that you depend on it)
# 4. Use JSON-compatible subset when interop matters
# 5. Watch for YAML 1.3 adoption in your parser

# Some tools (Cue, Dhall, Jsonnet) extend/replace YAML
# for complex configuration — consider them for new
# projects with advanced needs.

Was this helpful?

Learning path

Learn from scratch

Learn this language from the ground up with structured lessons.