Skip to content
datahacks.dev
YAML

YAML Syntax You Need to Stop Guessing At and Start Knowing

A complete guide to everyday YAML syntax: indentation, scalars, quoting, collections, block strings, anchors, null and versions, tested with real parsers.

19 min read
Summarize in AI
Flat illustration of a tree of nested objects beside the document it came from, with the YAML logo.

YAML is the format people write by hand more than any other, and the one they understand least. It looks like plain English with indentation, and it behaves like a language with a type system, a version history and a number of corners that surprise experienced developers. This guide covers the syntax you meet in everyday configuration, in the order you meet it, with the real behavior of real parsers. It ends with an annotated file that uses every feature and the checks that keep a file honest. The details live in the linked posts, and everything here was run, not remembered.

The model: three kinds of node

Everything in a YAML document is one of three things. A scalar is a single value: a string, an integer, a float, a boolean, a null or a timestamp. A sequence is an ordered list of nodes. A mapping is a set of key and value pairs, where the keys are scalars (almost always strings) and the values are any node. Documents nest these without limit, so a configuration file is a tree.

The syntax gives each kind two styles. Block style uses line breaks and indentation, and is what you read in most files. Flow style uses braces for mappings and brackets for sequences, like JSON, and is for short values. The two can be mixed, and a line like ports: [80, 443] is block mapping with a flow sequence inside.

The model explains most of the rest. Indentation decides which mapping a key belongs to. A dash starts a sequence item. A colon followed by a space separates a key from its value. Quoting decides whether a scalar is a string or something else. Once you see it as a tree built from those pieces, the odd error messages make sense, because every one comes from the parser failing to assemble the tree.

Indentation: the structure

Structure comes from leading spaces. Lines at the same indentation are siblings, a deeper line is a child of the line above, and the width of one level is free as long as one collection is consistent. Tabs are never allowed:

Text
while scanning for the next token | found character '\t' that cannot start any token | line 2, column 1

An extra space is another classic failure. A key indented one space deeper than its siblings is read as a continuation of the previous value:

Text
mapping values are not allowed here | line 3, column 5

The inverse mistake is silent. Moving a line two spaces left changes {'a': {'b': 1, 'c': 2}} into {'a': {'b': 1}, 'c': 2}, a different configuration with no error, because both documents are valid. Lists inside a mapping may sit at the key's own indentation or be indented, and both load identically. YAML indentation rules and why tabs break everything has all of these with their output, and the habits that prevent them: visible whitespace, an editor set to spaces, a linter in CI.

Scalars: what the parser decides for you

A plain scalar, one with no quotes, is whatever the parser resolves it to. It tries null, boolean, integer, float and timestamp, and if none matches it is a string. That is convenient for name: web and dangerous for anything that merely looks like another type. The test of 33 values shows how different the answers are between YAML versions:

WrittenPyYAML (1.1)ruamel.yaml (1.2)
yes, onTrue'yes', 'on'
NOFalse'NO'
010810
12:30750'12:30'
1e3'1e3'1000.0
1.101.11.1
2026-01-05a datea date

Ten of the 33 values differed between the two parsers. The famous one is the country code NO, which loads as False in a 1.1 parser, so a list of countries silently loses Norway (the Norway problem and YAML boolean surprises). The less famous ones cause as much damage: a version 3.10 that becomes the float 3.1, a Compose port mapping 22:22 that becomes the integer 1342, an identifier 010 that becomes eight. Why a number turns into something else in YAML lists the rest, and YAML 1.1 versus 1.2 explains which libraries implement which rules.

The defense is the same everywhere: quote anything that must be a string and could be read as another type. Codes, versions, identifiers, times, phone numbers, zip codes.

Quoting: three ways to write a string

A string can be plain, single-quoted or double-quoted. Single quotes are literal, and two single quotes in a row stand for one: 'it''s' is it's. Double quotes process escapes: "tab\there" contains a tab, "é" is é. A plain string cannot contain : or #, and cannot start with a character that has syntax meaning: *, &, !, %, @, a backtick, {, [, |, > or a quote. The failures are specific:

Text
msg: Error: disk full        -> mapping values are not allowed here
pattern: *.log               -> while scanning an alias: expected alphabetic or numeric character, but found '.'
token: @abc123               -> found character '@' that cannot start any token
bang: !important             -> could not determine a constructor for the tag '!important'
key: "unclosed               -> while scanning a quoted scalar: found unexpected end of stream

Each is fixed by quoting the value. YAML strings, quoting and when you need quotes goes through the rules and gives a policy: leave ordinary words unquoted, quote codes and anything with special characters, and lint the rest.

Collections: sequences and mappings

A block sequence is a dash and a space per item. A block mapping is key: value per line. Items can be collections themselves, which is how real files get their depth:

YAML
services:
  - name: web
    ports: [80, 443]
  - name: db
    env: {MODE: prod}

In a list of mappings, the keys after the first line up with the first key, two columns after the dash. The structure loads as a list of two dictionaries. Two traps recur. A key with nothing after the colon is null, not an empty list or mapping, so write [] or {} when you mean empty. And duplicate keys are silently overwritten by PyYAML ({'a': 2} from a document that had a: 1 and a: 2), caught by ruamel.yaml as a DuplicateKeyError and by yamllint as key-duplicates. YAML lists and mappings without confusion has the forms and the checks.

Block strings: text across lines

Long text uses block scalars. A pipe keeps line breaks; a greater-than sign folds them into spaces; a minus after the indicator strips the final newline and a plus keeps trailing blank lines. The same four lines of text under the six combinations gave six different strings:

Text
|   'line one\nline two\n\nline four\n'
|-  'line one\nline two\n\nline four'
|+  'line one\nline two\n\nline four\n\n'
>   'line one line two\nline four\n'
>-  'line one line two\nline four'
>+  'line one line two\nline four\n\n'

Use | for scripts, SQL and certificates, > for prose, and |- or >- when the value is compared with something, because a stray trailing newline breaks comparisons. Indentation inside the block is content, so extra spaces on a line are kept. YAML multiline strings with pipe and greater than has the details.

Null, empty and absent

YAML has several spellings of null: an empty value, ~, null, Null. Two quote marks with nothing between them are an empty string, a different thing. An empty list is []. An absent key is a fourth state. Loaded together:

Text
{'a': None, 'b': None, 'c': None, 'd': '', 'e': '', 'f': None}

The distinction matters in code. for x in cfg["features"] works on [] and fails on None, and a list whose items were all commented out is None. Pick a convention, write it down, and enforce it with a schema (YAML null values and the empty string question).

Comments

A hash begins a comment when it is at the start of a line or follows whitespace. Most parsers discard comments, so a program that loads a file, changes a value and saves it removes every comment unless it uses a round-trip library. ruamel.yaml keeps them and the key order, and yq keeps them with some normalization of spacing, while PyYAML drops them. Information a program must read belongs in a key, never in a comment (comments in YAML and what parsers do with them).

Anchors, aliases and merge keys

To say something once, YAML offers an anchor (&name), an alias (*name) and the merge key (<<). An aliased node is one shared object when loaded, so editing it in code changes every use, and the merge copies only the top-level entries of the anchored mapping, so a local nested mapping replaces the anchored one instead of combining with it. The merge key is a YAML 1.1 feature that the 1.2 core schema left out, yet most libraries support it. Use it for simple inheritance and keep the chains short (YAML anchors, aliases and merge keys without surprises).

Documents and JSON

A file can hold several documents separated by three dashes. safe_load accepts exactly one and raises expected a single document in the stream for more; safe_load_all reads them all. JSON is almost valid YAML, so a JSON document loads in a YAML parser and a file can start life as JSON and be edited into YAML. The comparison for configuration, with comments, types, speed (50,000 orders in 132 ms of JSON against 7.2 s of C-accelerated YAML) and safety is in YAML versus JSON for configuration.

An annotated file

Here is a realistic fragment that uses most of the features above, with the choices explained as comments:

YAML
# Deployment settings for the demo service. Owner: platform team.
defaults: &defaults          # reused below with a merge key
  retries: 3
  timeout: 30                # seconds; the upstream drops idle connections at 60
  log: {level: info}

services:
  - <<: *defaults
    name: web
    ports: ["80:80", "443:443"]   # quoted: unquoted 22:22 style values become numbers
    country: "NO"                 # quoted: otherwise False in YAML 1.1 parsers
    version: "3.10"               # quoted: otherwise the float 3.1
    banner: |-
      Welcome to the demo.
      Maintenance on Sundays.
    tags: []                      # explicitly empty, not null
  - <<: *defaults
    name: worker
    timeout: 120                  # local key overrides the anchor
    log: {level: debug}           # replaces the whole log mapping, merge is shallow
    schedule: ~                   # explicitly no schedule

Loaded with PyYAML, web has the defaults plus its own keys, the ports are strings, the country is the string NO, the version is 3.10, the banner is two lines with no final newline and tags is an empty list. worker has timeout 120, its log mapping replaced entirely (with level: debug only), and schedule is None. Every non-obvious choice has a reason in a comment, and none of the comments will survive a naive rewrite.

Reading real files: a CI workflow and a Compose file

Syntax knowledge pays off on the files you meet at work. Two of the most common are a CI workflow and a container Compose file, and both contain traps from the sections above. I loaded a small version of each with PyYAML and ruamel.yaml.

The workflow:

YAML
name: CI
on:
  push:
    branches: [main]
  pull_request:
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python: [3.9, 3.10, 3.11, "3.12"]
        os: [ubuntu-latest, windows-latest]
    steps:
      - uses: actions/checkout@v4
      - run: |
          pip install -r requirements.txt
          pytest -q
Text
PyYAML top-level keys: ['name', True, 'jobs']
PyYAML matrix python: [3.9, 3.1, 3.11, '3.12']
ruamel top-level keys: ['name', 'on', 'jobs']
ruamel matrix python: [3.9, 3.1, 3.11, '3.12']
steps[1].run repr: 'pip install -r requirements.txt\npytest -q\n'

Two lessons. The key on: became the boolean True under PyYAML, so a program that reads the workflow with it looks for the key "on" and does not find it. The service that runs workflows knows the key and handles it, and your own script may not. The 1.2 parser kept 'on'. The second lesson affects both parsers: 3.10 in the matrix loaded as the float 3.1, so the third Python version in the list is 3.1, not 3.10. In a 1.2 parser 3.10 is a float too, because it is a valid decimal number. The only fix is a quote, which is why the matrix above quotes "3.12" and not the others. Run 3.10 unquoted in a real workflow and the job asks for a Python version that does not exist (YAML version numbers turning into floats is dedicated to this). The run block, a literal scalar, kept its two lines and its final newline, as the clip default promises.

The Compose file:

YAML
services:
  web:
    image: nginx:1.25
    ports:
      - 80:80
      - 22:22
      - "8080:80"
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: example
      COUNTRY: NO
Text
compose ports (PyYAML): ['80:80', 1342, '8080:80']
compose db env (PyYAML): {... 'COUNTRY': False}
compose db env (ruamel): {... 'COUNTRY': 'NO'}

The port 80:80 stayed a string because 80 is not a valid base-60 digit group, 22:22 became the integer 1342, and the quoted "8080:80" stayed text. A tool that reads the port list with a 1.1 parser would try to publish port 1342. The container engine's own parser is 1.2-style and reads 22:22 correctly, which is exactly why this bug appears in scripts around the engine and not in the engine. And COUNTRY: NO is the Norway problem inside an environment mapping. For both files, the cure is the same three steps: quote what could be misread, lint, and load with the parser you use in production.

How a parser reads a line

Knowing the mechanism makes the error messages predictable. A YAML parser works in two stages. A scanner reads characters and produces tokens: it recognizes indentation changes, dashes, keys, colons, quotes, anchors and scalars. A parser assembles tokens into a tree. Errors come from one or the other, and the message tells you which: "while scanning" is the first stage, "while parsing" the second.

The scanner reads a plain scalar until it meets something that ends it: a colon followed by a space, a space followed by a hash, a line break followed by less indentation, or a comma or bracket inside flow style. That is why msg: Error: disk full fails: the scanner ended msg's value at Error, saw a colon and a space, and concluded that Error is a key in a nested mapping, which is not allowed on the same line. It is also why color: red #fff loses its tail: the scanner stopped at the space and hash.

The second stage resolves each plain scalar against the type rules for the version it implements, and this is where NO becomes False. The resolution is a list of patterns tried in order: null, then boolean, integer, float, timestamp, and finally string. A scalar becomes the first type whose pattern matches. Quoting skips the whole list: a quoted scalar is a string, full stop. That is the real reason quoting is the universal fix.

This also explains why errors point at lines slightly after the mistake. The scanner can only decide that something is wrong when it meets a token that does not fit, and that token is often on the next line. When the reported line looks innocent, read the line above.

Setting up the tools once

A few minutes of setup removes whole categories of errors for everyone who touches the files.

Editor. Use an .editorconfig file at the repository root: indent_style = space, indent_size = 2 and trim_trailing_whitespace = true for *.yaml and *.yml. Most editors honor it. Install a YAML language server extension, which gives you schema-based completion and red underlines for the faults described here.

Linter. Add a .yamllint file that extends the default and relaxes only what you decide to, such as the line length and the requirement for a document start. Run yamllint . in CI, so a failed lint fails the build.

Pre-commit. A hook that runs the linter on staged YAML files catches problems before review. Keep the hook fast; yamllint takes milliseconds per file.

Schema. For files with a defined shape, publish a JSON Schema and validate in CI with check-jsonschema. Editors can use the same schema for completion. In the test, one run reported a misspelled key, a wrong type, a value outside an enum and a duplicate list item, each with the path to the failing value.

A loader test. One unit test that loads each config file with the production parser and asserts the types of the values the application reads. It costs ten lines and catches the version differences that the linter cannot see.

None of this requires agreement on a style guide beyond a few lines in the configuration files, and all of it runs without anybody remembering anything.

Habits that keep files honest

Lint on every change. yamllint reported nine problems in a deliberately messy sample, including truthy for yes, colons for extra spaces, brackets, hyphens, line-length, indentation and comments-indentation, and returned a nonzero exit code that fails a CI job (linting YAML with yamllint covers configuration).

Validate the shape. A schema turns "it parsed" into "it is right". check-jsonschema reported a misspelled key, a string where an integer belongs, a value outside an enum and a duplicate list item in one run (validating config YAML with JSON Schema).

Read the diff for structure. A reindented line is a structural change. A reviewer who ignores whitespace in a YAML diff misses the bug.

Test with the real parser. Files travel between tools that implement different language versions. Load the file with the parser that runs in production, and check the types your program depends on.

Use libraries to write YAML. String-built YAML breaks on the first value with a colon. Let ruamel.yaml or yaml.safe_dump handle quoting (writing YAML from Python without ugly output).

Keep files small and shallow. Past a few hundred lines or four levels, split by concern (splitting a large YAML config into several files).

What "valid YAML" guarantees

When a linter says a file is valid, it has made a small promise. The scanner and parser accepted the text and produced a tree. It has not promised that the tree means what you intended, that the keys are the ones your program reads, that any value has the type your program expects or that two different parsers would build the same tree. Four distinct levels of correctness are in play, and mixing them up causes most of the confusion about YAML.

Syntax. The text is well-formed. This is what yaml.safe_load checks by not raising. It catches tabs, bad indentation and unquoted colons.

Style. The text follows conventions: consistent indentation, no trailing spaces, no duplicate keys. This is what a linter checks beyond syntax, and it catches things the parser tolerates.

Shape. The tree has the keys, nesting and value types the consumer needs. This is what a schema checks. A misspelled key, a string where a number belongs, an unexpected extra key: all valid YAML, all wrong.

Meaning. The values are right for the system: a port that is free, an image that exists, a version that is supported. Only the system, or a test against it, can say.

Each level needs its own tool, and each tool is cheap. The mistake is to stop at the first. A team that runs a parser in CI and calls the files "validated" has checked the lowest level only.

A review checklist for YAML changes

When you review a pull request that changes YAML, these are the questions that find the problems the tools miss. They are the lessons of this guide in a form you can apply to a diff.

  • Did any indentation change? A moved line is a moved key. Check the parent of every line whose indentation differs.

  • Is there a new unquoted value that is a code, a version or an identifier? If it contains digits and colons, leading zeros, or looks like a boolean, ask for quotes.

  • Is a new key spelled the way the consumer expects? A schema would catch it; without one, compare against the documentation.

  • Did a list lose all its items? An empty list that is really null changes behavior.

  • Does an override replace more than intended? A merge is shallow, and a local nested mapping replaces the anchored one.

  • Are there duplicate keys after the merge of branches? The last wins silently.

  • Do the comments still match the values? A comment that says 30 next to a value of 60 is a bug in either.

  • Were secrets added? Tokens, passwords and keys do not belong in the file, even in a comment.

  • Was the file reformatted? A whole-file whitespace change hides real changes. Ask for the formatting commit to be separate.

A review that asks these nine questions of each hunk is slower than a skim and faster than the incident that the skim would have allowed. Pair it with automation so people spend attention on the questions only a person can answer.

Learning it by doing

The fastest way to fix your own YAML habits is a short exercise. Take a configuration file you maintain, load it with a parser, print it as JSON and read the output slowly. Look for four things: any value whose type you did not intend, any key you cannot find, any list that is null and any string that has lost a leading zero. People who do this on a file they have worked with for a year usually find at least one value that has been wrong all along and that nobody noticed because the program had a default.

Then break the file on purpose, one change at a time, and read the error. Add a tab. Remove a space. Add an unquoted colon. Write NO in a list. Comment out every item of a list. The messages will stay in your head, and the next time one appears in a real file you will recognize the cause in seconds instead of minutes. The error catalog in fixing YAML errors that make no sense is the companion to this exercise, since it shows what each mistake looks like in three different parsers.

Finally, write down the three or four YAML rules that your team has been bitten by. A short list in the repository's contributing notes, with the quoting rule first, prevents more mistakes than any amount of tooling, because it tells people why the tooling is complaining.

A debugging order

When a file misbehaves, work in this order, which is the cheapest first:

  1. Run it through a parser and print the result as JSON. Most mistakes are visible in the printed structure.

  2. Run yamllint. It finds tabs, duplicates and indentation faults by name.

  3. Read the error's line and column, and also the line before: the parser often gives up one line late.

  4. Check the types of any value that looks like a code, a number with a leading zero, a boolean word or a time.

  5. Try a second parser. If PyYAML and yq disagree, you have a version difference, not a typo.

  6. Reduce the file until the problem disappears.

Fixing YAML errors that make no sense takes the error messages one at a time.

The posts in this cluster each take one topic in depth: indentation, quoting, multiline strings, lists and mappings, anchors, comments, null, the 1.1 and 1.2 versions and a one-page cheat sheet. For loading and writing YAML in code, read reading and writing YAML in Python safely.

The sources: the YAML 1.2.2 specification, the YAML 1.1 specification, the PyYAML documentation and the yamllint documentation. Convert and inspect a snippet in the YAML to JSON tool or reformat it in the YAML formatter.

When this guide's rules do not hold

The tool defines its own YAML. Kubernetes, Compose, GitHub Actions and Ansible all read YAML and then apply their own schema and sometimes their own preprocessing, such as templating. The syntax here holds; what is valid is decided by their documentation.

A template engine runs first. Helm charts and similar systems render text before parsing, so indentation and quoting are produced by templates. Debug the rendered output, not the template.

You use features this guide skips. Complex keys, sets, ordered mappings, custom tags and the YAML directives are in the specification and nearly absent from real configuration. They also differ most between libraries.

Your parser differs from the ones tested. Results here are for PyYAML 6.0.3, ruamel.yaml 0.19.1 and yq 4.54.1. Other libraries and other versions can behave differently, particularly on the 1.1 and 1.2 differences. Test the parser you use with the handful of ambiguous values before trusting a file.

The data is not configuration. For large or tabular data, YAML is slow and verbose. Use CSV, JSON or a binary format, and keep YAML for settings.

Also inTooling
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.