YAML pitfalls: indentation, the Norway problem and anchors
YAML is pleasant to read, which is why Kubernetes, GitHub Actions, GitLab CI, Docker Compose, Ansible and a long list of applications use it. The price is a large specification with two incompatible versions in common use, implicit typing that changes values behind your back, and whitespace that carries meaning. This guide covers the pitfalls that break real configuration, each tested against two JavaScript parsers: js-yaml and the yaml package, the latter in both YAML 1.1 and 1.2 mode.
Why the version matters
YAML 1.1 (2005) has generous implicit typing: dozens of words are booleans, numbers with leading zeros are octal, and 22:22 is a base-60 number. YAML 1.2 (2009) cut this back to a JSON-compatible core. The catch is that many popular parsers still follow 1.1 rules by default, including PyYAML, Ruby's Psych and the go-yaml library that Kubernetes tooling builds on. Here is one file read both ways:
countries: [GB, FR, NO]
enabled: on
mode: 0755
zip: 01234
port: 22:22
| Key | YAML 1.2 result | YAML 1.1 result |
|---|---|---|
countries | ["GB","FR","NO"] | ["GB","FR",false] |
enabled | "on" | true |
mode | 755 | 493 (octal) |
zip | 1234 | 668 (octal) |
port | "22:22" | 1342 (base 60) |
Neither column is what the author meant for zip. The practical conclusion: the same file means different things in different tools, so write YAML that is unambiguous under both versions. In practice that means quoting.
The Norway problem
Under YAML 1.1, yes, no, on, off, y, n, true and false, in several capitalisations, are booleans. A list of ISO country codes loses Norway, which becomes false. It bites in less obvious places too:
- Load a GitHub Actions workflow with PyYAML and the top-level
on:key comes back as the booleanTrue. GitHub's own parser handles it, but your linting script will not. - A Kubernetes env var written
value: yesorvalue: trueis rejected, because env values must be strings and the parser produced a boolean. Writevalue: "true". - A feature-flag file with
country_override: NOsilently turns into "override disabled".
Fix: quote any string that could be read as something else, such as "NO", "on" or "yes".
Numbers that are not numbers
- Versions:
python: 3.10is the float3.1in every parser. Plenty of CI matrices meant to test "Python 3.10" have asked for Python 3.1 instead.1.10.0survives only because two dots make it a string. - Leading zeros: postal codes, account numbers and file modes lose zeros or become octal, as shown above.
- Scientific notation: an ID like
12e3becomes12000. - Hex:
0x1Fbecomes31. - Dates:
date: 2026-10-09becomes a JavaScriptDatein js-yaml and adatetime.datein PyYAML, not a string. That surprises code that calls string methods on it.
Fix: quote versions, codes, IDs, modes and dates you want as text: python: "3.10".
Comments start with " #"
A # preceded by whitespace starts a comment, including right after the colon. This catches everyone at least once:
color: #ff0000 # null: everything after the space is a comment
color: "#ff0000" # "#ff0000"
password: abc #123 # "abc"
tag: c#sharp # "c#sharp": no space before #, so not a comment
Hex colours, channel names, passwords and URLs with fragments all need quotes when they start with or contain #.
Indentation and tabs
Structure comes from indentation, and only spaces are allowed. A tab in the indentation is a hard error (tab characters must not be used in indentation). A misaligned line is sometimes an error:
env:
- name: LOG_LEVEL
value: debug
- name: PORT
value: "8080" # bad indentation of a sequence entry (5:4)
The dangerous case is when misalignment produces a valid document with a different shape. Here cert was meant to be under tls:
tls:
enabled: true
cert: /etc/tls/cert.pem
This parses cleanly to {"tls":{"enabled":true},"cert":"/etc/tls/cert.pem"}. The application reads tls.cert, finds nothing, and either falls back to a default or fails at runtime far from the cause. Configure your editor for two-space indentation with visible whitespace, and when a file behaves strangely, convert it to JSON with YAML ⇄ JSON to see the structure the parser actually built.
The space after the colon
A key needs ": ", a colon followed by a space. In a list, - key:value is the single string "key:value". As a line inside a mapping it is an error. This is also why url: http://example.com:8080/a works unquoted: the colons in the value are not followed by spaces.
Characters that need quotes at the start
* starts an alias, so host: *.example.com fails with unidentified alias. &, !, |, >, %, @, the backtick, {, [ and - also have special meanings at the start of a value. handle: @dev is an error. Quote them all.
Multi-line strings
literal: |
echo one
echo two
folded: >
echo one
echo two
strip: |-
last
That parses to "echo one\necho two\n", "echo one echo two\n" and "last". Use | for scripts, certificates and anything where newlines matter. A CI run: block written with > becomes one long line, and a multi-line shell script turns into a single command with surprising arguments. The trailing - strips the final newline, which matters for values like tokens compared byte for byte.
Anchors, aliases and merge keys
Anchors (&name) and aliases (*name) let you define a block once and reuse it. The merge key << folds a mapping into another:
defaults: &defaults
timeout: 30
retries: 3
production:
<<: *defaults
timeout: 60
js-yaml produces {"timeout":60,"retries":3} for production, which is what was intended. The yaml package in its default 1.2 mode produces {"<<":{"timeout":30,"retries":3},"timeout":60}: a literal key named <<, and no retries, because merge keys are a YAML 1.1 extension that 1.2 never adopted. It needs merge: true. Docker Compose and GitLab CI support merge keys; some other tools do not, and fail silently.
Two more costs. Converting to JSON expands every alias, and that is how "billion laughs" attacks blow up memory with nested aliases. Use a parser with alias limits on untrusted input, and never load untrusted YAML with a full loader that can construct objects, such as PyYAML's yaml.load without SafeLoader. And readers must jump around the file to know the real values. Use anchors where the repetition is real, and check the expanded result.
Duplicate keys
The spec says keys must be unique, but parsers disagree on enforcement. js-yaml (duplicated mapping key) and the yaml package (Map keys must be unique) reject the file. PyYAML keeps the last value silently. In a 300-line config, that means an edit near the top can have no effect at all. Lint with yamllint, whose key-duplicates rule is on by default.
A defensive checklist
- Quote strings that could be booleans, numbers, dates, null or comments.
- Two spaces per level, never tabs.
|for scripts and certificates.- Anchors only where they remove real repetition; check merge-key support in your tool.
- Lint in CI with yamllint and with the same parser your runtime uses.
- When in doubt, convert to JSON and look. The YAML Formatter reports the line and column of syntax errors.