Skip to main content
DevOps from zero · Lesson 21 of 25 · 15 min

YAML Tutorial for Beginners: Maps, Lists, Quotes and Traps

YAML tutorial for beginners: maps, lists, indentation, multi-line strings and quoting, plus the traps that turn on, NO and 3.10 into other values.

By Matthews WongReviewed on

Key takeaway

YAML is a plain-text format for structured data: indentation with spaces nests maps (key: value) and lists (- item), and every other value is a scalar. Docker Compose and GitHub Actions both read it. A parser, not you, decides each unquoted value's type, so on, NO, 0755 and 3.10 can turn into a boolean or a different number unless you quote them.

You will be able to

  • Read any YAML file as a tree of maps, lists and scalars, and say what each indented line belongs to.
  • Write multi-line strings with | and >, and know which values need quotes in YAML 1.1 and 1.2.
  • Parse a file to JSON on your laptop to see exactly what a tool will read, and tell YAML from Compose's ${VAR} and GitHub's ${{ }}.
Watch it work

Step through the diagram

Press Play, or step with the arrows. Every step is written out below the diagram.

How a parser turns YAML into a tree

A Compose file goes through a YAML parser before Compose sees it: indentation decides what nests under what, a tab stops the parser, unquoted values get a type from how they look, and Compose fills in its own variables only after parsing.

Step 1 of 7. A YAML file is only text. A parser reads it and builds a tree of maps, lists and scalars, and Docker Compose acts on that tree, never on the text.

Keys: ← → step · Home / End · K play or pause

  1. A YAML file is only text. A parser reads it and builds a tree of maps, lists and scalars, and Docker Compose acts on that tree, never on the text.

  2. A key indented deeper than the key above it is its child: web under services, ports under web. A dash starts a list item, so ports holds a list.

    services: web: ports:Predict the next step Next, ports: moves two spaces left, level with web:. Where does the parser put it? (Still inside web · Beside web, as a second service)
  3. Level with web, ports becomes its sibling: a second service named ports. The YAML is valid, so the parser is happy. Compose is not: services.ports must be a mapping.

    services: web: ports:
  4. YAML forbids tabs in indentation. One tab before a key and the parser stops at that line, so Compose never gets a tree: found character that cannot start any token.

    found character that cannot start any tokenPredict the next step Next, the list item is 22:22 with no quotes, read by a YAML 1.1 parser. What lands in the tree? (The text 22:22 · The number 1342 · A parse error)
  5. Unquoted values get a type from how they look. To a YAML 1.1 parser, 22:22 is base 60: the number 1342. Docker's reference says to always quote port mappings.

    - 22:22
  6. Quoted, "22:22" is a string in every YAML version, and Compose gets exactly what you wrote. This site's port entry is quoted too, as Docker's reference asks.

    - "22:22"
  7. Inside a string, a dollar-sign variable means nothing to YAML. Compose fills it in after parsing, from the shell or .env, and GitHub Actions evaluates its expressions the same way.

    published: "3002"

What is YAML?

YAML is a data serialization language: a way to write structured data as plain text that people can read and edit. Its specification calls it human-friendly and cross-language. Every YAML document is built from three kinds of nodes: mappings (key: value pairs), sequences (lists) and scalars (single values such as a string or a number).

Read more (2)

You meet it all over DevOps. Docker Compose describes an application in a YAML file, compose.yaml by default, and still accepts docker-compose.yml, the older name this site uses. GitHub Actions workflows use YAML syntax and must end in .yml or .yaml. Version 1.2 of the spec set out to make YAML a strict superset of JSON, so a JSON document is valid YAML too.

One idea runs through this lesson: a tool never reads your text. A YAML parser reads it first and hands the tool a tree of maps, lists and typed values. Every trap below is a gap between the tree you meant and the tree the parser built.

How does indentation create structure?

Indentation is the structure. The spec's rule: each node must be indented further than its parent, and all siblings must use exactly the same indentation. How many spaces is your choice, because the amount is a presentation detail; this site's files use two. What counts is that siblings line up.

Read more (2)

Indent with spaces only. The spec forbids tab characters in indentation, because different systems treat tabs differently. In a test on the research date, one tab before a key stopped all three parsers tried: PyYAML, js-yaml and Docker Compose, which reported found character that cannot start any token.

The dangerous mistake is the one that still parses. Move ports two spaces left, level with web, and it stops being a setting of web: it becomes a second entry under services, a service named ports. The YAML is valid, so only the tool can complain, and Compose did: services.ports must be a mapping.

Maps, lists, flow style and comments

A mapping entry is key: value, with a colon and a space. A sequence entry starts with a dash and a space. They nest freely: in this site's workflow, steps is a list, and each item in it is a map with keys such as name, uses and with.

Read more (3)

Both have a flow style borrowed from JSON: square brackets for a list, braces for a map. branches: [main] in the workflow is the same list as a dash on its own line, and the compose file's health check test is a flow list spread over several lines. Inside brackets and braces, a plain value cannot contain brackets, braces or commas.

A key with nothing after it holds null: parse this site's workflow and workflow_dispatch comes back as null. Keys in one map must be unique. Given a duplicate key, js-yaml and Compose stopped with an error, while PyYAML silently kept the last value.

A # starts a comment that runs to the end of the line, and comments have no effect on the tree. It must be separated from the value by whitespace, so color: #fff is not a colour: the parser sees a key with an empty value, which is null, then a comment. A # with no space before it, as in a URL fragment, stays part of the value.

How do you write multi-line strings with | and >?

A block scalar holds text over several lines, indented under its key. With | (literal), every line break is kept. With > (folded), single line breaks become spaces, so a long sentence can wrap in the file and still be one line. The indentation in front of the block is removed; the parser keeps only the text.

Read more (2)

The character after | or > controls the end. Plain | keeps exactly one final newline, the default the spec calls clip. |- strips it, and |+ keeps every trailing blank line. In a test, two lines under | came back as two lines ending in a newline, and the same lines under > came back joined by a space.

This site's deploy step writes its shell script under script: |, because each line must stay its own command. GitHub's run: works the same way; its reference says that when you provide multi-line commands, each line runs in the same shell.

When do you need quotes in YAML?

An unquoted value is a plain scalar, and the parser decides its type by matching it against a schema. Under the YAML 1.2 core schema, only true and false (in three capitalisations) are booleans; null, Null, NULL, ~ and an empty value are null; integers are decimal, 0o octal or 0x hex; and numbers with a dot or an exponent are floats. Anything that matches none of these is a string.

Read more (3)

YAML 1.1 matched far more. Its boolean type includes y, n, yes, no, on and off in several capitalisations; its integers treat a leading 0 as octal and accept base 60, so 190:20:30 is a number. That is where the famous traps come from: to a YAML 1.1 parser, country: NO is false and the key on: is true.

One trap survives both versions. 3.10 matches the float rule in YAML 1.1 and 1.2 alike, so it becomes the number 3.1. Every parser tested turned an unquoted 3.10 into 3.1, which is why version numbers belong in quotes; GitHub's own Python tutorial writes its matrix as quoted strings such as "3.9" and "3.10".

Quote a value when it is text that looks like something else: a version, a code with a leading zero, a country code, yes or no, on or off, a time-like 22:22. Quote it too when it starts with a character YAML reserves: * starts an alias, & an anchor, ! a tag, | and > a block, # a comment, and @ and the backtick are reserved. Double quotes allow backslash escapes; single quotes take the text literally, with two apostrophes for one.

Which parser reads your file?

Whether NO is a boolean depends on the parser, and the tools you use do not agree. PyYAML describes itself as a complete YAML 1.1 parser, so it reads on, NO and yes as booleans. js-yaml calls itself a YAML 1.2 parser and leaves them as text. Docker Compose loads files with the Go yaml package, which supports most of YAML 1.2 but keeps some YAML 1.1 behaviour for backwards compatibility.

Read more (2)

The table below is a test from the research date. Compose kept yes, NO and on as text, but read 0755 as the octal number 493, so an environment entry written MODE: 0755 rendered as the string 493. Docker's Compose reference still asks you to quote yes and no in environment, and to write port mappings as quoted strings because of base-60 parsing. Following that advice is easier than checking which rules your version uses.

GitHub Actions reads on: as its trigger key, as every example in its reference shows. Read this site's deploy.yml with PyYAML, though, and the top-level keys come back as name, True and jobs, which breaks any script that looks the triggers up under on. GitHub's reference also warns that ! is reserved notation in YAML, so a condition that starts with ! must be wrapped in ${{ }} or quoted.

Anchors, aliases and merge keys

An anchor, &name, labels a node; an alias, *name, repeats it elsewhere in the same file. Compose supports both, plus the merge key <<, which copies a map's entries into another map where you can override some of them. Keep the shared block in a top-level x- field, which Compose ignores. Its reference adds two limits: merge works on maps, not lists, and anchors are resolved before variables are interpolated, so a variable cannot name an anchor.

Read more (1)

GitHub Actions has supported anchors and aliases since September 2025, for example to reuse one env block in two jobs. Its documentation does not mention merge keys, so do not rely on << in a workflow. For anything bigger than a repeated block, GitHub has reusable workflows.

Are ${VAR} and ${{ }} part of YAML?

No. YAML has no variables; to the parser, ${APP_PORT:-3002} in this site's compose file is ordinary text inside a string. Compose replaces it afterwards with its Bash-like interpolation: ${VAR} or $VAR, ${VAR:-default} when the variable is unset or empty, ${VAR:?message} to stop with an error. Values come from your shell first, then a file passed with --env-file, otherwise a .env file next to the compose file. Interpolation applies to values only, never to keys, and $$ writes a literal dollar sign.

Read more (2)

docker compose config shows the result: it parses, resolves and renders the file in canonical form, with --format json for JSON and --no-interpolate to see the raw text. With nothing set, this site's port entry renders as published 3002; with APP_PORT=8080 in the shell, as 8080.

${{ }} is GitHub's expression syntax. Its docs say the special syntax tells GitHub to evaluate an expression rather than treat it as a string; to the YAML parser, host: ${{ secrets.VPS_SSH_HOST }} is just a string that starts with a dollar sign. In a run script, GitHub evaluates the expressions and substitutes the results into the script text before the shell runs it, which is why untrusted input never belongs inside ${{ }} in a script.

How this site does it

The real config behind this lesson

Excerpts from the repository that builds and deploys this website, quoted as they are.

docker-compose.yml:24-38
    ports:
      # Bind to loopback only — nginx terminates TLS and proxies to this port.
      - "127.0.0.1:${APP_PORT:-3002}:3000"
    mem_limit: 512m
    cpus: "1.0"
    healthcheck:
      test:
        [
          "CMD",
          "wget",
          "--no-verbose",
          "--tries=1",
          "--spider",
          "http://127.0.0.1:3000/robots.txt",
        ]

Three kinds of value in fifteen lines. The port entry is a quoted string, as Docker's reference asks for port mappings, and its ${APP_PORT:-3002} is filled in by Compose after parsing. mem_limit: 512m needs no quotes because 512m matches no number rule. test is a flow list, brackets and commas spread over lines, and parses to the same list that dashes would.

.github/workflows/deploy.yml:7-10
on:
  push:
    branches: [main]
  workflow_dispatch:

on: is the key GitHub reads for triggers. A YAML 1.2 parser such as js-yaml keeps it as on; PyYAML, a YAML 1.1 parser, returns this same key as True. branches: [main] is a one-item flow list, and workflow_dispatch: has nothing after it, so it parses as null.

.github/workflows/deploy.yml:213-222
      - name: Deploy over SSH
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.VPS_SSH_HOST }}
          username: ${{ secrets.VPS_SSH_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          port: ${{ vars.VPS_SSH_PORT || 22 }}
          script: |
            set -euo pipefail
            cd "${{ vars.DEPLOY_PATH }}"

The dash makes this step one item of the steps list, and every key indented under it belongs to that item. Each ${{ }} value is a plain string to YAML, even unquoted, because it starts with a dollar sign; GitHub evaluates it later, || included. script: | keeps every line of the shell script on its own line, with the twelve spaces of indentation removed.

Put it in order

The real lines, shuffled

Five lines from this site's compose file, shuffled and stripped of their indentation. Each one is the parent of the next. Put them back in order, outermost first.

From docker-compose.yml

  1. web:
  2. services:
  3. options:
  4. logging:
  5. max-size: "10m"
Show the real order
services:
web:
logging:
options:
max-size: "10m"

YAML nests by indentation, so each line here sits one level deeper than the one before. services holds the services, web is one of them, logging is a setting of web, options belongs to the logging driver, and max-size is one option. Change any line's level and the parser builds a different tree.

What breaks

Mistakes that cost real time

  • found character that cannot start any token

    A tab is hiding in the indentation, for example pasted from somewhere else or typed by an editor set to tabs. YAML forbids tabs there, and the parser stops at that line. Turn on visible whitespace in your editor, replace the tab with spaces, and set the editor to indent YAML with spaces.

  • services.ports must be a mapping

    A key slipped one level, so it now belongs to a different parent. The YAML is valid, which is why the parser said nothing and the tool complained about a name you never meant as a service. Run docker compose config to see the tree Compose built, and line the key up with its real siblings.

  • A version or file mode that changed on the way in

    version: 3.10 comes back as 3.1 from every parser tested, and Compose rendered an environment entry of 0755 as 493. Nothing errors; the value is just different. Quote anything that is text, even when it looks like a number: "3.10", "0755".

  • A script cannot find the on key in a workflow

    Your own tooling reads workflows with PyYAML, a YAML 1.1 parser, so on: comes back as the boolean True. GitHub itself is fine; your script is not. Use a YAML 1.2 parser such as js-yaml, or look the triggers up under True when you must use PyYAML.

  • A value that starts with *, ! or #

    paths: **.js is read as an alias, and both parsers tested failed on it; if: !cancelled() is read as a YAML tag, which GitHub's reference warns about. A value starting with # is a comment, so it becomes null. Quote the value, or for a GitHub condition wrap it in ${{ }}.

The same unquoted values through three parsers, tested on 2026-10-18 (Compose values read from an x- field with docker compose config). Quoted, every one stayed the text as written.
on: (as a key)truethe text onthe text on
NOfalsethe text NOthe text NO
yestruethe text yesthe text yes
0755493 (octal)755 (decimal)493 (octal)
3.103.13.13.1
22:221342 (base 60)the text 22:22the text 22:22
#fffnull (a comment)null (a comment)null (a comment)
Try it yourself

Do it on your own machine

You need Python 3, Node.js with npx, and Docker with Compose; Compose only renders files here and starts nothing. Each step prints what a parser really built, so you see the traps instead of taking them on trust.

  1. Make a lab folder and a file of eight lines that look harmless.

    mkdir yaml-lab && cd yaml-lab
    cat > traps.yaml <<'EOF'
    on: push
    country: NO
    debug: yes
    version: 3.10
    mode: 0755
    port: 22:22
    color: #fff
    quoted: "3.10"
    EOF
  2. Read it with js-yaml, a YAML 1.2 parser, which prints JSON. on, NO and yes stay text, but version is already 3.1, mode is 755 and color is null.

    npx --yes js-yaml@4 traps.yaml
  3. Read it with PyYAML, a YAML 1.1 parser, in a throwaway virtual environment. The on key comes back as true, NO as false, yes as true, 0755 as 493 and 22:22 as 1342. Only quoted stays "3.10".

    python3 -m venv .venv
    .venv/bin/pip install --quiet pyyaml
    .venv/bin/python -c 'import json, sys, yaml; print(json.dumps(yaml.safe_load(open(sys.argv[1])), indent=2))' traps.yaml
  4. Now ask Docker Compose. Write a compose.yaml with three of the traps in environment and print what Compose would run: MODE is "493", VERSION is "3.1" and DEBUG stays "yes".

    cat > compose.yaml <<'EOF'
    services:
      demo:
        image: alpine:3
        environment:
          MODE: 0755
          VERSION: 3.10
          DEBUG: yes
        ports:
          - "127.0.0.1:${APP_PORT:-8080}:80"
    EOF
    docker compose config --format json
  5. Interpolation happens after parsing: the port renders as 8080 by default, as 9090 when the shell sets APP_PORT, and as the raw text with --no-interpolate.

    docker compose config | grep published
    APP_PORT=9090 docker compose config | grep published
    docker compose config --no-interpolate | grep 127.0.0.1
  6. Quote the three values and render again: MODE is "0755", VERSION is "3.10" and DEBUG is "yes", exactly as written.

    sed -i.bak -e 's/0755$/"0755"/' -e 's/3\.10$/"3.10"/' -e 's/yes$/"yes"/' compose.yaml
    docker compose config --format json | grep -A4 '"environment"'
  7. Break the indentation on purpose. With ports moved two spaces left, Compose says services.ports must be a mapping. Put it back, then try a tab in front of image: the parser stops before Compose sees anything.

    sed -i.bak 's/^    ports:/  ports:/' compose.yaml
    docker compose config
    sed -i.bak 's/^  ports:/    ports:/' compose.yaml
    printf 'services:\n  demo:\n\timage: alpine:3\n' > tab.yaml
    docker compose -f tab.yaml config
  8. Clean up the lab folder.

    cd .. && rm -rf yaml-lab
Quick quiz

Three questions before you move on

Pick an answer to see why it is right or wrong. Nothing is graded, so guess freely; a miss is kept in this browser so the course can ask it again later.

  1. 1.GitHub's reference says if: !cancelled() must be wrapped in ${{ }} or quoted. Why?
  2. 2.Which line gives every YAML parser the string 3.10?
  3. 3.This site's compose file has - "127.0.0.1:${APP_PORT:-3002}:3000". What turns ${APP_PORT:-3002} into 3002?
From earlier lessons

Two questions from earlier lessons

Recalling an older lesson after a gap is what makes it stick. Answer from memory before you look anything up.

  1. 1.You bind-mount an empty host folder over /usr/share/nginx/html in the nginx image. What is in that folder inside the container?
  2. 2.Your app on a cloud VM cannot connect to any recipient's MX host on port 25, while HTTPS calls work. What is the most likely cause?

From: 20. Docker Volume vs Bind Mount: Where Container Data Lives · 18. How SMTP Works: Ports, MX Lookup, SPF, DKIM and DMARC

Think it through

Answer in your head, then check

  1. 1.Why does PyYAML turn the on: key of a GitHub workflow into True, while GitHub reads it fine?Show the answer

    Answer:PyYAML is a YAML 1.1 parser, and YAML 1.1's boolean type includes on, off, yes and no. The YAML 1.2 core schema only treats true and false as booleans, so a 1.2 parser keeps on as text, and GitHub reads it as the trigger key.

  2. 2.What is the difference between script: | and script: >?Show the answer

    Answer:| keeps every line break, so a shell script stays one command per line. > folds single line breaks into spaces, so the lines become one long line. Both keep one final newline unless you add - to strip it.

  3. 3.image: and ports: both belong to web:. What must be true of their indentation?Show the answer

    Answer:Both are indented further than web:, with exactly the same number of spaces as each other, and with spaces, never tabs. Shift one of them to web's level and it becomes a sibling of web instead.

Go deeper

Related deep dives on this site