AnsibleX · YAML for Reliable AutomationYAML for reliable automation
The traps that do not raise an error
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
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
$ 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.ymlwhere a key was added at the top months after an identical one existed at the bottom; - a role’s
defaults/main.ymlafter a merge that resolved a conflict by keeping both sides; - a task with
become: truenear the top andbecome: falsenear 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.
$ 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:
$ ansible-playbook -i localhost, colon2.ymlok: [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.
$ 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:
$ 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 1Prevent it rather than diagnose it, with four lines in the repository root:
[*.{yml,yaml,j2}]
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = trueThree tools, three different jobs
This is the section to remember. The tools are not alternatives.
| Tool | Reads | Catches | Misses |
|---|---|---|---|
yamllint | the file as text | duplicate keys, tabs, line length, trailing space, truthy values, indentation style | anything requiring knowledge of what a playbook is |
ansible-playbook --syntax-check | the file as a playbook | parse errors, unknown play keywords, malformed task lists, missing hosts | anything that is valid YAML and a valid structure |
ansible-lint | the file as Ansible semantics | unquoted octal modes, command where a module exists, missing changed_when, misindented task keywords, deprecated syntax, become misuse | anything inside a script, or a value that is simply wrong |
yamllint .
ansible-playbook --syntax-check -i inventories/prod site.yml
ansible-lintyamllint 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:
yamllintis clean, withkey-duplicatesandtruthyat error level andoctal-valuesenabled (it is off by default).--syntax-checkpasses on every entry-point playbook.- Every octal mode is a quoted string, or symbolic, everywhere it is
defined — including
group_varsand 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;
.editorconfigpresent. - Multi-line shell commands use
|, never>.
Knowledge check
Knowledge check · 4 questions
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?
Q2. Which of these are caught by yamllint but not by ansible-playbook --syntax-check? Select all that apply.
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.
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.