Chapter 4: Playbooks — The Core
Topic 19

YAML for Ansible

ConceptSyntax

Every playbook, inventory, and variable file is YAML, and the handful of YAML rules that bite Ansible users are not the ones the spec authors warn about. They are indentation, implicit type coercion, and the quoting decisions Ansible inherits from PyYAML. A file that fails to parse stops the run cleanly, but the worse case is a file that parses into the wrong types and runs anyway.

A playbook that breaks with a confusing error is almost always a YAML problem wearing an Ansible costume. Learn the four rules below and most of those errors stop being mysteries: you read the message, recognize the YAML cause, and fix it in the right place rather than three lines down from where the parser pointed.

Indentation Is the Syntax

YAML expresses nesting through indentation — two spaces per level, never tabs — and there are no braces or brackets to fall back on when the indentation is wrong. A task indented one space too far quietly becomes a child of the wrong key, and the structure the parser builds is not the structure you meant. Because the document is still technically valid YAML, the error often surfaces several lines later as "a task is not a dictionary," pointing at the symptom rather than the cause.

This is why the first thing to check on any baffling parse error is alignment. Open the file in an editor that renders whitespace, confirm every sibling key starts in the same column, and confirm not a single tab slipped in. The fix is almost never where the error message points; it is wherever two lines that should be siblings are not in the same column.

Lists vs Dictionaries

YAML has two container shapes, and Ansible's structure is built by nesting them. A leading - makes a list item; a plain key: value makes a mapping, also called a dictionary. A playbook's tasks: is a list of dictionaries — each task is one list item that is itself a dictionary of a name and a module call. Keeping that shape in your head is what lets you read a playbook's indentation as structure.

Confusing the two reshapes the document into something Ansible cannot run. A stray - in front of name: turns a single task into two malformed half-tasks; a missing - collapses two tasks into one dictionary with duplicate keys. The Larkspur site.yml is a list of plays, each play a dictionary, each holding a tasks: list of dictionaries — the same two shapes all the way down.

The Truthiness Trap

YAML coerces a long list of bare words to booleans: yes, no, true, false, on, and off all become true or false. It also reads bare numbers as integers and floats, and certain bare strings as dates. So a host named on, a ZIP code 02134, or a version 1.10 silently becomes a bool, an octal integer (02134 is read in base 8 as 1116, not the 2,134 you meant), or a float that lost its trailing digit.

Some of this coercion is exactly what you want: become: yes is meant to be a boolean, and Ansible relies on that. The bug is the unquoted value you intended as a string that YAML turns into something else. The rule that keeps you safe is to quote anything that could be misread, and to never trust a bare word to stay a word.

Quoting Discipline

Quote any scalar that contains a :, starts with {{, or could be read as a number, bool, or date. The sharpest case is a Jinja2 expression at the start of a value: msg: {{ item }} is a parse error, because YAML sees the leading { and tries to read a dictionary. Written as msg: "{{ item }}" it parses as a string and Jinja2 renders it at run time, which is why a templated value at the start of a line is always quoted.

Quoting that keeps types and Jinja2 honest in the Larkspur web play
- hosts: web
  become: yes                    # a real boolean — correct unquoted
  vars:
    app_version: "1.10"          # quoted, or YAML makes it the float 1.1
    listen_port: 443             # a real integer — correct unquoted
  tasks:
    - name: Show the target host
      ansible.builtin.debug:
        msg: "{{ ansible_hostname }} on port {{ listen_port }}"

The pattern is mechanical: when in doubt, quote. Quoting a value that did not need it costs nothing, while leaving one unquoted that did is a bug that can install the wrong package version or compare against a string that never matches. Treat quotes as the default for any value with a special character or an ambiguous shape, not as decoration you add only when something breaks.

The three YAML traps that bite Ansible users
Booleans
Bare yes, no, true, off all coerce to a bool — a string you meant silently becomes true.
Quoting
A value starting with {{ reads as a dictionary and fails — quote any Jinja2 expression at the start of a value.
Indentation
Two spaces per level, never tabs — one space off reparents a key, and the error surfaces lines away.

key: value vs key=value Module Args

Module arguments come in two forms. The structured YAML form puts each argument on its own indented line — name: nginx under the module key — and is the readable default for playbooks, because it diffs cleanly and reviews well. The inline form, name=nginx state=present on the module line itself, is the compact shorthand the ad-hoc -a flag uses on the command line.

Each form is fine in its place; mixing them inside one task is the mistake. Writing inline name=nginx state=present on the same task that also carries a structured become: yes produces an ambiguous parse failure rather than a clear error. Pick the structured form for everything you check into a playbook and reserve the inline form for one-line ad-hoc commands.

Common Mistakes
  • Indenting with a tab anywhere in the file — YAML rejects tabs outright, the editor that inserted it shows nothing visibly wrong, and the parse error reads as a mystery until you reveal whitespace.
  • Leaving a version or ID unquoted, like version: 1.10, and having YAML drop the trailing zero to the float 1.1, so the wrong package version installs with no error at all.
  • Writing msg: {{ ansible_hostname }} unquoted — YAML reads the leading {{ as a dictionary and fails; a Jinja2 expression at the start of a value must be quoted.
  • Mixing inline name=nginx state=present with a structured become: yes on the same task and getting an ambiguous parse failure instead of a clear message about what is wrong.
  • Relying on yes or no as string values — an answer field, a hostname — and having them coerced to booleans, so a string comparison later never matches what you expected.
Best Practices
  • Configure your editor for two-space indentation and a no-tabs rule on .yml files, and run yamllint in CI so structural errors never reach a playbook run.
  • Quote every scalar that starts with {{, contains a :, or could be misread as a number, bool, or date, rather than guessing which ones YAML will coerce.
  • Use the structured key: value argument form in playbooks and reserve key=value for ad-hoc -a, so playbooks stay diffable and reviewable.
  • Validate parsing with ansible-playbook --syntax-check site.yml before a real run, catching YAML and structure errors without touching a single host.
  • Treat quotes as the default for any ambiguous value, since a needless quote costs nothing while a missing one is a silent type bug.
Comparable tools Puppet · Chef manifests and a Ruby DSL that avoid YAML entirely Terraform HCL with explicit types and no truthiness coercion Kubernetes manifests the exact same YAML footguns, so the discipline transfers yamllint the linter that catches these before a run

Knowledge Check

Why does a single tab character break a YAML file in a way that is hard to spot?

  • YAML rejects tabs for indentation outright, yet most editors render a tab and spaces identically, so nothing looks wrong
  • Tabs are perfectly valid indentation in the YAML spec everywhere, but Ansible layers on its own extra rule that specifically forbids them inside playbook files at parse time
  • A tab silently doubles the indentation depth of its line, quietly shifting the nesting of everything that follows
  • Editors strip the tab out on save, so the file loses its block structure on disk without you noticing

You write app_version: 1.10 unquoted in a vars block. What does YAML hand to Ansible?

  • The float 1.1 — the trailing zero is dropped, so the wrong version string is used with no error
  • The string "1.10" exactly as you typed it, since YAML preserves version-like values verbatim
  • A parse error at load time, because any value containing a dot must always be quoted in YAML
  • The integer 110, with YAML dropping the dot and concatenating the digits

Why must msg: {{ ansible_hostname }} be written as msg: "{{ ansible_hostname }}"?

  • An unquoted leading {{ is read by YAML as the start of a dictionary, so the line fails to parse before Jinja2 ever runs
  • Jinja2 only ever evaluates the expressions that are wrapped in double quotes, and silently ignores any {{ }} that is left unquoted, leaving the raw braces in the value
  • The quotes are what tell Ansible to go and gather the ansible_hostname fact before the task runs
  • Without the quotes YAML would coerce the whole value to a boolean instead of a string

When is the inline key=value argument form the right choice over the structured YAML form?

  • In a one-line ad-hoc command passed to -a, where compactness matters and there is nothing to review or diff
  • In any task inside a playbook, because the inline form parses measurably faster than the structured YAML form
  • In every playbook task, because it is the only argument form that supports Jinja2 expressions
  • Whenever a task also sets become, so the escalation and the arguments stay on one line

You got correct