# 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.

URL: https://www.matthewswong.com/en/learn/devops-from-zero/yaml-tutorial-for-beginners/

DevOps from zero · Lesson 21 of 25 · Reviewed on 18 October 2026 · By Matthews Wong (https://www.matthewswong.com/en/about/)

> 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 ${{ }}.

## 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.

1. **Meet the parts.** 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. **Indentation is nesting.** 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:* 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. **A two-space slip.** 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. **A tab stops the parser.** 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 token
   ```

   *Predict:* 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. **The parser picks the type.** 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. **Quotes make it text.** 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. **The tool's own syntax.** 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).

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.

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.

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.

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.

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.

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.

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.

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.

## The real config behind this lesson

`docker-compose.yml:24-38`

```yaml
    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`

```yaml
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`

```yaml
      - 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.

## What breaks

- **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.

| In the file | PyYAML 6.0.3 (YAML 1.1) | js-yaml 4.3 (YAML 1.2) | Docker Compose v5.5.1 |
| --- | --- | --- | --- |
| on: (as a key) | true | the text on | the text on |
| NO | false | the text NO | the text NO |
| yes | true | the text yes | the text yes |
| 0755 | 493 (octal) | 755 (decimal) | 493 (octal) |
| 3.10 | 3.1 | 3.1 | 3.1 |
| 22:22 | 1342 (base 60) | the text 22:22 | the text 22:22 |
| #fff | null (a comment) | null (a comment) | null (a comment) |

## Try it yourself

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.

   ```bash
   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.

   ```bash
   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".

   ```bash
   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".

   ```bash
   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.

   ```bash
   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.

   ```bash
   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.

   ```bash
   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.

   ```bash
   cd .. && rm -rf yaml-lab
   ```

## Quick quiz

1. **GitHub's reference says if: !cancelled() must be wrapped in ${{ }} or quoted. Why?**
   A. cancelled() is not a GitHub function
   B. YAML reads a leading ! as a tag, not as text
   C. if: only accepts the words true or false
   Answer: B. ! is the tag indicator in YAML, so a parser reads !cancelled() as a tag, not as text; both parsers tested rejected it. Inside ${{ }} or quotes, the ! is just a character in a string, and GitHub evaluates it as its not operator.

2. **Which line gives every YAML parser the string 3.10?**
   A. version: "3.10"
   B. version: 3.10
   C. version: 3.10 # keep as text
   Answer: A. Unquoted, 3.10 matches the float rule in both YAML 1.1 and 1.2 and becomes 3.1. A comment changes nothing, because comments have no effect on the tree. Only the quotes make it a string.

3. **This site's compose file has - "127.0.0.1:${APP_PORT:-3002}:3000". What turns ${APP_PORT:-3002} into 3002?**
   A. The YAML parser, while it reads the file
   B. The shell on the server, before Compose starts
   C. Docker Compose, after the YAML is parsed
   Answer: C. YAML has no variables, and no shell ever reads the inside of the compose file. Compose interpolates the value after parsing, using APP_PORT from the shell or .env, or 3002 when it is unset or empty.

## Think it through

- **Why does PyYAML turn the on: key of a GitHub workflow into True, while GitHub reads it fine?** 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.
- **What is the difference between script: | and script: >?** | 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.
- **image: and ports: both belong to web:. What must be true of their indentation?** 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

- [KYAML in Kubernetes 1.37: Stable YAML Subset, kubectl -o kyaml](https://www.matthewswong.com/en/blog/kyaml-kubernetes-stable-yaml-format/)
- [How to Deploy Docker Compose in Production for Beginners](https://www.matthewswong.com/en/blog/cara-deploy-docker-compose-production-vps/)
- [How to Create CI/CD with GitHub Actions: 7 Beginner Steps](https://www.matthewswong.com/en/blog/cara-membuat-ci-cd-github-actions-pemula/)
- [Reusable Workflows vs Composite Actions in GitHub Actions](https://www.matthewswong.com/en/blog/github-actions-reusable-workflows-composite-actions/)

## Sources

- [YAML 1.2.2 specification (indentation, comments, block scalars, core schema)](https://yaml.org/spec/1.2.2/)
- [YAML 1.1 boolean type (y, yes, on, off)](https://yaml.org/type/bool.html)
- [YAML 1.1 integer type (leading-zero octal, base 60)](https://yaml.org/type/int.html)
- [YAML 1.1 float type](https://yaml.org/type/float.html)
- [Docker docs: Compose interpolation](https://docs.docker.com/reference/compose-file/interpolation/)
- [Docker docs: Set, use and manage variables with interpolation (.env, precedence)](https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/)
- [Docker docs: Compose fragments (anchors, aliases, merge)](https://docs.docker.com/reference/compose-file/fragments/)
- [Docker docs: Compose extensions (x- fields)](https://docs.docker.com/reference/compose-file/extension/)
- [Docker docs: Compose services reference (quoting ports and environment booleans)](https://docs.docker.com/reference/compose-file/services/)
- [Docker docs: docker compose config](https://docs.docker.com/reference/cli/docker/compose/config/)
- [Docker docs: How Compose works (the Compose file and its names)](https://docs.docker.com/compose/intro/compose-application-model/)
- [GitHub Docs: Workflow syntax (on, run, ! in if conditions)](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)
- [GitHub Docs: Expressions](https://docs.github.com/en/actions/concepts/workflows-and-actions/expressions)
- [GitHub Docs: Script injections](https://docs.github.com/en/actions/concepts/security/script-injections)
- [GitHub Docs: Reusing workflow configurations (YAML anchors and aliases)](https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations)
- [GitHub changelog: Actions supports YAML anchors (2025-09-18)](https://github.blog/changelog/2025-09-18-actions-yaml-anchors-and-non-public-workflow-templates/)
- [GitHub Docs: Building and testing Python (quoted version matrix)](https://docs.github.com/en/actions/tutorials/build-and-test-code/python)
- [PyYAML: a complete YAML 1.1 parser](https://pyyaml.org/wiki/PyYAML)
- [js-yaml: YAML 1.2 parser for JavaScript](https://github.com/nodeca/js-yaml)
- [go-yaml: the Go YAML package (YAML 1.1 compatibility notes)](https://github.com/yaml/go-yaml)

---

- HTML version: https://www.matthewswong.com/en/learn/devops-from-zero/yaml-tutorial-for-beginners/
- Bahasa Indonesia: https://www.matthewswong.com/id/learn/devops-from-zero/yaml-tutorial-for-beginners/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
