Writing and Checking YAML
So far the parser has been reading. This chapter is the other direction — programs writing YAML — plus the two habits that keep hand-edited files honest: linting, and loading safely.
Generating YAML: the dumper
PyYAML's safe_dump turns data back into YAML
text. Feed it a dictionary with one of each
awkward value:
$ python3 -c "
import yaml
d = {'service': 'beacon', 'debug': 'yes', 'retries': 2, 'notes': None, 'release': '2026-10-17', 'announcement': 'Beacon 2.4 ships on Friday with a faster checker and fewer false alarms for everyone on the team'}
print(yaml.safe_dump(d), end='')
"
announcement: Beacon 2.4 ships on Friday with a faster checker and fewer false alarms
for everyone on the team
debug: 'yes'
notes: null
release: '2026-10-17'
retries: 2
service: beacon
Four quirks, all visible:
- Keys come out sorted.
announcementbeforeservice, though the dictionary had the opposite order. Sorting is the default; it's harmless to parsers and noisy in diffs. - Strings that would re-read as another type
get quoted. The string
'yes'is dumped quoted — unquoted, it would come back as a boolean. The string'2026-10-17'is quoted for the same reason: bare, it would return as a date. The dumper protects its own output; this quoting is the traps of chapter 8, defended automatically. - Long lines wrap. The announcement folded at 80 columns, with the continuation indented — a folded plain scalar that reads back as one line with a space where the break was. (The wrap width is a setting; the default is 80.)
- Null is spelled
null, whatever spelling the input used.
Dump a whole parsed file and a fifth quirk appears — sequences are written flush with their key, not indented under it:
$ python3 -c "
import yaml
d = yaml.safe_load(open('service.yml'))
out = yaml.safe_dump(d)
print('\n'.join(out.splitlines()[:8]))
"
alerts:
email:
- ops@example.com
- oncall@example.com
repeat_after_minutes: 30
webhook: https://hooks.example.com/beacon
checks:
interval_seconds: 60
Legal YAML, same data — and a style most humans wouldn't choose. A dumped file is machine output; treat it as such.
Round-tripping — load, dump, load again — preserves the data, not the text:
$ python3 -c "
import yaml
d = yaml.safe_load(open('service.yml'))
back = yaml.safe_load(yaml.safe_dump(d))
print(back == d)
print(list(back))
"
True
['alerts', 'checks', 'features', 'logging', 'server', 'service']
Equal, and reordered. Comments, of course, do
not survive at all: the parser dropped them on
the way in. A config with human comments should
be edited as text, never round-tripped through
a dumper. And if the order matters to your
readers, sort_keys=False keeps the file's
order instead:
service:
name: beacon
version: 2.4.1
environment: production
Linting: yamllint
A parser checks "is this YAML?" A linter —
yamllint (pip install yamllint; version
1.38.0 here) — checks "is this good YAML by
the rules we chose?" It catches the class of
mistakes parsers forgive. The headline example
is chapter 8's duplicate key:
$ yamllint duplicate-keys.yml; echo "[exit $?]"
duplicate-keys.yml
8:3 error duplication of key "retries" in mapping (key-duplicates)
[exit 1]
Line 8, column 3, the rule named. Errors fail
the run (exit 1); warnings don't. The shipped
.yamllint starts from yamllint's default rules
and adjusts two — line length becomes a
100-column warning, and the --- document-start
marker isn't required of these fragments. Under
it, watch what gets flagged in perfectly valid
files:
$ yamllint -c .yamllint service.yml traps.yml; echo "[exit $?]"
service.yml
44:16 warning truthy value should be one of [false, true] (truthy)
traps.yml
5:10 warning truthy value should be one of [false, true] (truthy)
6:14 warning truthy value should be one of [false, true] (truthy)
7:9 warning truthy value should be one of [false, true] (truthy)
8:8 warning truthy value should be one of [false, true] (truthy)
9:9 warning truthy value should be one of [false, true] (truthy)
20:1 warning truthy value should be one of [false, true] (truthy)
[exit 0]
The truthy rule is the Norway problem as a
lint rule: it warns on every 1.1-only boolean
spelling — service.yml line 44 is the
beta_checks: off from chapter 9 — while
passing the file, because warnings are advice.
Run yamllint in your editor or your pipeline
and the loudest YAML traps become somebody
else's job to catch. yamllint also reports the
broken files' syntax errors (chapter 11 shows
two), in the same one-line format.
Loading safely
YAML's full type system can describe program objects, not just data — and some loaders will build them. In Python this is concrete. Watch what the unsafe loader does with a two-line "config":
$ python3 -c "import yaml; print(yaml.unsafe_load('x: !!python/object/apply:os.system [\"echo this command ran\"]'))"
this command ran
{'x': 0}
The text this command ran was printed by a
shell command embedded in the YAML. The value
that came back, 0, is that command's exit
status. The file didn't describe data; it
described an action, and the loader performed
it. With a different command in place of echo,
loading an untrusted config is running
untrusted code — this is a real, named class of
vulnerability, not a curiosity.
The safe loader refuses the same input:
yaml.constructor.ConstructorError: could not determine a constructor for the tag 'tag:yaml.org,2002:python/object/apply:os.system'
The rules are short enough to memorise:
- Always
yaml.safe_load(or your language's equivalent). It builds only strings, numbers, booleans, lists, mappings, and standard types. - Never
unsafe_load,Loader=yaml.Loader, or their kin on a file you didn't write — and treat config files from the internet as untrusted input, because they are. - PyYAML 6 enforces part of this for you: the
old bare
yaml.load(text)now refuses to run at all —
$ python3 -c "import yaml; yaml.load('service: beacon')"
TypeError: load() missing 1 required positional argument: 'Loader'
— which is the library insisting you say, out loud, how much power you're handing the file. Choose the safe one.