Skip to main content
RunBook Academy

AnsibleX · YAML for Reliable AutomationYAML for reliable automation

The traps that do not raise an error

Intermediate⏱ ~17 minansible-playbookyamllintansible-lint

What you'll learn

  • Recognise the duplicate-key warning and understand which value survives
  • Predict when a colon inside an unquoted value is a parse error and when it is not
  • Evaluate anchors, aliases and merge keys against maintainability
  • Distinguish what yamllint, --syntax-check and ansible-lint each catch
  • Assemble the YAML items of a pre-commit checklist

Prerequisites

Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11

Not yet marked complete on this device.

A parse error is the good outcome. It stops the run, names a line, and costs a minute.

This lesson is about the other outcome: a file that parses into something you did not mean, runs successfully, and produces a result nobody connects back to the YAML. Then it is about the three separate tools it takes to catch those, and why one tool is not enough.

Duplicate keys: the last one wins

Read-only / Safeexecuted against ansible-core 2.21.3
$ ansible-playbook -i localhost, dupkeys.yml
[WARNING]: Found duplicate mapping key 'listen_port'.
Origin: /home/ops/estate/dupkeys.yml:7:5

5   vars:
6     listen_port: 8080
7     listen_port: 9090
    ^ column 5

Using last defined value only.

TASK [Show the port] ***********************************************************
ok: [localhost] => {
  "msg": "listen_port is 9090"
}

ansible-core 2.21 warns and continues. That is a considerable improvement on silence, and it is still a warning in a stream that routinely contains several — deprecation notices, interpreter-discovery notes, callback chatter. On a fleet run it scrolls past.

Where this actually bites is not a six-line file. It is:

  • a 200-line group_vars/all.yml where a key was added at the top months after an identical one existed at the bottom;
  • a role’s defaults/main.yml after a merge that resolved a conflict by keeping both sides;
  • a task with become: true near the top and become: false near the bottom, where the surviving value is the one you did not intend.

The last of those is a privilege bug that reads as a permissions mystery.

Colons inside unquoted values

The rule is exact and narrower than most people assume: a colon in an unquoted scalar is a parse error only when followed by whitespace.

Read-only / Safethe loud case
$ ansible-playbook --syntax-check -i localhost, colon.yml
[ERROR]: YAML parsing failed: Colons in unquoted values must be followed by a non-space character.
Origin: /home/ops/estate/colon.yml:8:32

6     - name: Log a message
7       ansible.builtin.debug:
8         msg: Restarting service: nginx
                               ^ column 32

For example:

  raw: echo 'name: ansible'

Should be:

  raw: "echo 'name: ansible'"

That is the friendly failure. Here is the quiet one, from the types lesson, and it is the same character:

Read-only / Safethe quiet case
$ ansible-playbook -i localhost, colon2.yml
ok: [localhost] => {
  "msg": "url=http://example.com/health (str) window=750 (int) ratio=181 (int)"
}

One character, two entirely different failure modes, and only one of them tells you. The habit that covers both: quote any value containing a colon.

Anchors, aliases and merge keys

YAML can name a node and reuse it. Ansible’s YAML loader supports this, and it works.

Read-only / Safeanchors and the merge key, executed
$ ansible-playbook -i localhost, anchors.yml
[WARNING]: Found duplicate mapping key 'max_procs'.
Origin: /home/ops/estate/anchors.yml:11:7

9     api_limits:
10       <<: *common_limits
11       max_procs: 8192
       ^ column 7

Using last defined value only.

TASK [Show the resolved structures] ********************************************
ok: [localhost] => {
  "msg": "api_limits={'open_files': 65535, 'max_procs': 8192} worker_limits={'open_files': 65535, 'max_procs': 4096}"
}

Two things in that output are worth stopping on.

The merge worked correctly. api_limits inherited open_files from the anchor and overrode max_procs. That is the intended semantics of the merge key.

And it produced a duplicate-key warning anyway. The override is exactly what a merge key is for, and ansible-core 2.21.3 reports it as a duplicate. So the one YAML feature whose whole purpose is “inherit and override” trips the check you just decided to treat as an error in CI.

Tabs

A tab in indentation is a parse error, and the message is unambiguous:

Read-only / Safeexecuted against 2.21.3
$ ansible-playbook --syntax-check -i localhost, tabs.yml
[ERROR]: YAML parsing failed: Tabs are usually invalid in YAML.
Origin: /home/ops/estate/tabs.yml:5:1

3   gather_facts: false
4   tasks:
5  - name: nope
^ column 1

Prevent it rather than diagnose it, with four lines in the repository root:

Read-only / Safe.editorconfig
[*.{yml,yaml,j2}]
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true

Three tools, three different jobs

This is the section to remember. The tools are not alternatives.

ToolReadsCatchesMisses
yamllintthe file as textduplicate keys, tabs, line length, trailing space, truthy values, indentation styleanything requiring knowledge of what a playbook is
ansible-playbook --syntax-checkthe file as a playbookparse errors, unknown play keywords, malformed task lists, missing hostsanything that is valid YAML and a valid structure
ansible-lintthe file as Ansible semanticsunquoted octal modes, command where a module exists, missing changed_when, misindented task keywords, deprecated syntax, become misuseanything inside a script, or a value that is simply wrong
Read-only / Safethe pre-commit trio
yamllint .
ansible-playbook --syntax-check -i inventories/prod site.yml
ansible-lint

yamllint is the one people skip, and it is the one that catches the defects in this lesson. Its truthy rule is worth calling out specifically: it flags yes, no, on and off used as booleans, which is the type trap from lesson 2 caught statically rather than at 03:00. It ships at warning level, so raise it.

The YAML pre-commit checklist

These are the items this part contributes to the repository’s commit checklist. Every one of them corresponds to a defect demonstrated in one of the six lessons:

  • yamllint is clean, with key-duplicates and truthy at error level and octal-values enabled (it is off by default).
  • --syntax-check passes on every entry-point playbook.
  • Every octal mode is a quoted string, or symbolic, everywhere it is defined — including group_vars and role defaults.
  • Every value containing a template is quoted.
  • Every value containing a colon is quoted.
  • Every value whose type matters is quoted: versions, country codes, zero-padded identifiers, time windows.
  • when: and the other expression keywords carry no {{ }} delimiters.
  • No tabs; .editorconfig present.
  • Multi-line shell commands use |, never >.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A 200-line group_vars file defines become: true at line 12 and become: false at line 180. What happens when the play runs on ansible-core 2.21?

  2. Q2. Which of these are caught by yamllint but not by ansible-playbook --syntax-check? Select all that apply.

  3. Q3. Using a YAML merge key to inherit a mapping and override one of its keys produces a duplicate-key warning in ansible-core 2.21.

  4. Q4. msg: Restarting service: nginx fails to parse, but url: http://example.com/health does not. What distinguishes them?

Passing score: 75%. Answers are checked in this browser.