YAML doesn't make you quote strings, so the parser guesses the type of every unquoted value. The gotchas are the cases where it guesses wrong: the country code NO becomes false, the file mode 0755 becomes 493, the version 1.10 becomes 1.1, and a backup time of 22:30 becomes 1350. Which of those happen depends on the YAML version your parser implements, and two tools reading the same file can disagree.
To see what a parser actually read, look at the data, not the text. Paste a file into the YAML to JSON converter and the JSON shows every value with its type: a quoted string, a bare number, true, false or null. Every example below was run through PyYAML 6.0, ruamel.yaml 0.19, the npm packages yaml 2.9 and js-yaml 5.5, Go's yaml.v3, sigs.k8s.io/yaml 1.6 and Docker Compose 5.5; the output shown is theirs.
YAML 1.1 vs 1.2: which one is your parser?
YAML 1.1 (2005) resolves a long list of plain words to booleans: y, yes, on and true, their opposites, each in lower, title and upper case. It reads a leading zero as octal and colon-separated digits as base 60. YAML 1.2 (2009) replaced this with a core schema where only true and false are booleans, octal is written 0o755, and base 60 is gone. Libraries didn't all follow, and some mix the two:
| Parser | NO | 0755 | 22:30 | on: as a key |
|---|---|---|---|---|
PyYAML (safe_load) | false | 493 | 1350 | True |
ruamel.yaml, yaml (npm), js-yaml | "NO" | 755 | "22:30" | "on" |
| Go gopkg.in/yaml.v3 | "NO" | 493 | "22:30" | "on" |
| sigs.k8s.io/yaml (kubectl) | false | 493 | "22:30" | "true" |
| devprix converter | false | 755 | "22:30" | "on" |
PyYAML is 1.1 throughout and ignores a %YAML 1.2 directive. kubectl converts manifests to JSON with sigs.k8s.io/yaml, which keeps 1.1 booleans and octal. yaml.v3 reads booleans the 1.2 way but keeps leading-zero octal. The devprix converter reads yes, no, on and off in values as booleans but numbers the 1.2 way, so it shows the Norway problem but not 1.1 octal or base 60. And y and n are booleans in the 1.1 spec, but PyYAML leaves them as strings.
The Norway problem: yes, no, on and off
countries: [GB, NO, SE] # PyYAML: ['GB', False, 'SE']
debug: yes # PyYAML: True
cache: on # PyYAML: True
The name comes from country lists where Norway's ISO code silently became false. It bites wherever a short code or a yes/no answer is data, not a flag. In a Kubernetes manifest, value: yes on a container env var comes out of kubectl's YAML-to-JSON step as "value": true. Env values are strings, so the API server rejects the object with an error ending in "cannot unmarshal bool into Go struct field … of type string". value: 8080 fails the same way as a number. Quote both: value: "yes", value: "8080".
The Compose reference asks you to quote true, false, yes and no in environment, and quotes restart: "no". Compose 5.5 kept DEBUG: yes as "yes", but a PyYAML-based script reading the same file won't.
GitHub Actions workflows start with on:. GitHub reads that as the key "on", but load the workflow with PyYAML and you get:
{'name': 'CI', True: {'push': {'branches': ['main']}}, 'jobs': {...}}
so wf["on"] raises KeyError and wf[True] works. yamllint's default truthy rule warns on that line too; set check-keys: false for workflow files or write the key as "on":.
Numbers that shouldn't be numbers
Octal. Under 1.1 rules a leading zero means octal, and Compose's Go parser keeps that rule. This service:
environment:
UMASK: 0022
MODE: 0755
ZIP: 01234
APP_VERSION: 1.10
comes out of docker compose config as:
environment:
APP_VERSION: "1.1"
MODE: "493"
UMASK: "18"
ZIP: "668"
The container gets UMASK=18. A 1.2 parser reads 0755 as decimal 755, which is wrong in a different way, and its octal spelling 0o755 is just a string to PyYAML. Quote modes and convert them in code; the chmod 755 vs 644 guide explains why the digits matter.
Versions. 1.10 is a float in every YAML version, so it loads as 1.1. A GitHub Actions matrix entry python-version: 3.10 asks setup-python for Python 3.1. 1.10.0 is safe, because two dots don't make a number, but quote versions anyway.
Base 60. YAML 1.1 reads 22:30 as 22 × 60 + 30 = 1350 whenever each part after the first is 0 to 59. A Compose port list in PyYAML shows the edge exactly: - 22:22 loads as 1342, while - 8080:80 stays a string because 80 isn't a valid base-60 digit. That is why the Compose reference says HOST:CONTAINER should always be a quoted string.
Tabs, colons and the # sign
- Tabs can't indent YAML. PyYAML says "found character '\t' that cannot start any token", the npm package says "Tabs are not allowed as indentation", and the devprix converter says "Tabs can't be used for indentation in YAML" with the line number. Tabs inside quoted strings and block scalars are fine.
- Colon plus space inside an unquoted value starts a nested mapping.
title: Note: read thisfails in PyYAML with "mapping values are not allowed here". URLs andnginx:1.27are fine because no space follows the colon. The devprix converter is lenient and returns "Note: read this", so it won't catch this one. - Space then # starts a comment.
color: #ff0000is null,tag: abc #123is "abc", andhash: abc#123keeps the whole string. - A leading * or & means an alias or anchor. An unquoted
cron: */15 * * * *fails with "while scanning an alias" in PyYAML, and the devprix converter points at the line and suggests the quotes; quote every schedule from the cron syntax guide. - Quotes differ. Single quotes are literal and
''is the only escape. Double quotes process backslash escapes, so'tab\there'keeps the backslash while"tab\there"contains a real tab. Windows paths belong in single quotes.
Multi-line strings: | and >, with chomping
script: |
echo one
echo two
folded: >
This is a long
sentence.
New paragraph.
strip: |-
no newline
keep: |+
keep both
last: end
Every parser tested, including the devprix converter, returns:
{
"script": "echo one\necho two\n",
"folded": "This is a long sentence.\nNew paragraph.\n",
"strip": "no newline",
"keep": "keep both\n\n",
"last": "end"
}
| (literal) keeps every line break; > (folded) joins lines with spaces and turns a blank line into a single break. The suffix controls the end of the string: none keeps one trailing newline, - strips it, + keeps all trailing blank lines. Lines indented further than the first keep their extra indentation in both styles.
The trailing newline matters. A password under a Kubernetes Secret's stringData written with | ends in a newline that becomes part of the password; use |- for anything compared byte for byte. run: | in Actions and >- for a long one-line command are the usual choices.
Anchors, aliases and merge keys
x-defaults: &defaults
restart: unless-stopped
logging:
driver: json-file
options:
max-size: 10m
services:
web:
<<: *defaults
image: nginx:1.27
worker:
<<: *defaults
image: busybox:1.36
restart: "no"
&defaults names a node, *defaults reuses it, and << merges its keys into the current mapping. Keys you write win, so worker ends up with restart: "no". docker compose config prints both services with the logging block expanded. Three catches:
- The merge is shallow. Add
logging: {options: {max-size: 1m}}to a service and you replace the wholeloggingmap;driveris gone. <<is a YAML 1.1 type, not part of 1.2. PyYAML, ruamel.yaml, yaml.v3 and Compose apply it. The npmyamlpackage leaves a literal"<<"key unless you pass{ merge: true }, and js-yaml 5.5 did the same in our test.- The devprix converter expands anchors, aliases and
<<the same way, so its JSON shows what each service ends up with. It stops at an alias with no anchor before it, and at aliases that would expand past 5 million characters, the pattern behind "billion laughs" YAML bombs.
Duplicate keys and empty values
image: nginx:1.25
ports: ["80:80"]
image: nginx:1.27
The spec says mapping keys are unique, but enforcement varies. PyYAML silently keeps the last value, nginx:1.27. The npm package stops with "Map keys must be unique", ruamel.yaml raises DuplicateKeyError, yaml.v3 and Compose report mapping key "image" already defined at line 1, and the devprix converter stops with Duplicate key "image": it's already set on line 1. A key that overrides one brought in by << is not a duplicate.
Nulls are consistent: ~, null, Null, NULL and an empty value load as null everywhere, while "null" and "" are strings.
Convert to JSON to see what the parser read
Paste this into the converter:
countries: [GB, NO, SE]
debug: yes
mode: 0755
version: 1.10
backup_at: 22:30
color: #ff0000
With Compact arrays ticked, the JSON makes three surprises obvious:
{
"countries": ["GB", false, "SE"],
"debug": true,
"mode": 755,
"version": 1.1,
"backup_at": "22:30",
"color": null
}
Because the converter reads numbers the 1.2 way, check octal and base-60 readings with the library your tool uses:
python3 -c 'import sys, json, yaml; print(json.dumps(yaml.safe_load(sys.stdin), indent=2, default=str))' < config.yaml
yq -o=json . config.yaml
docker compose config
For a long file, the data viewer shows the same parse as a tree with a type badge on every node (str, num, bool, null), so one bool in a list of strings stands out, and you can copy its JSONPath. Going from JSON to YAML, the converter quotes strings that would change type, writing "on": push, country: "NO", mode: "0755" and version: "1.10".
Next steps
Quote any string that looks like a boolean, number, time or alias, write booleans as true and false, indent with spaces, and check the result in the YAML / JSON converter. For the JSON side, see how to fix common JSON syntax errors; for Compose files, docker run vs docker compose.