Skip to main content
RunBook Academy

AnsibleX · YAML for Reliable AutomationYAML for reliable automation

The YAML a playbook actually is

Foundation⏱ ~15 minansible-playbook

What you'll learn

  • Name the three YAML node types and identify each in a playbook
  • State the structural shape of a playbook file from the top down
  • Read an indentation error as a claim about structure
  • Diagnose conflicting action statements and malformed block errors
  • Use --syntax-check as the first response to a structural problem

Prerequisites

None — start here.

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.

This part is not a YAML course. It is the subset of YAML that changes what your automation does, and this first lesson is the structural half of that: what a playbook file is, so that when the parser rejects it you can read the message as a statement about structure rather than as an accusation about whitespace.

If you have written a Docker Compose file, you have met this language already — the Docker course covers Compose YAML, and every type trap in the next lesson applies there identically. This is not Ansible weirdness. It is the language.

Three node types, and that is all

YAML has exactly three kinds of node, and everything in a playbook is one of them.

NodeWritten asIn a playbook
Scalarnginx, 8080, true, '0644'Every value that is not a container.
Sequencelines beginning - tasks, handlers, the playbook file itself.
Mappingkey: value pairsA play, a task, a vars block, a module’s arguments.

Indentation is how nesting is expressed. Two spaces per level is the convention; the parser only requires consistency within a block, but consistency across a repository is worth enforcing mechanically.

The shape, from the top

A playbook file is a sequence of plays. Each play is a mapping. Some of that mapping’s keys hold sequences of tasks. Each task is a mapping again.

Configuration changethe whole structure, annotated
# The file is a SEQUENCE. Each '-' at column 1 begins one play.
- name: First play                    # play: a MAPPING
  # (keys of the play mapping)
hosts: webservers                   # scalar
become: true                        # scalar
vars:                               # MAPPING
  app_port: 8080
  app_user: appsvc
tasks:                              # SEQUENCE of tasks
  - name: Install nginx             # task: a MAPPING
    ansible.builtin.package:        # module: a MAPPING of arguments
      name: nginx
      state: present
    notify: Reload nginx            # scalar, a key of the TASK
handlers:                           # SEQUENCE
  - name: Reload nginx
    ansible.builtin.service:
      name: nginx
      state: reloaded

# A second play in the same file.
- name: Second play
hosts: dbservers
tasks:
  - name: Say hello
    ansible.builtin.debug:
      msg: hello

Three relationships in that file are the ones people get wrong, and they are worth stating as rules:

  1. name and the module name are siblings — both are keys of the task mapping.
  2. A module’s arguments are one level deeper than the module name. name: nginx under ansible.builtin.package: is an argument; name: at the task’s own level is the task’s name.
  3. Task keywords like when, notify, register, loop, become and tags are keys of the task, at the same level as the module name — not arguments of the module.

Rule 3 is the one that produces most first-month errors, and the next section is what it looks like when you break it.

Reading the two error classes

Valid YAML, wrong structure

Read-only / Safemsg indented one level too shallow
$ ansible-playbook -i localhost, struct4.yml
[ERROR]: conflicting action statements: ansible.builtin.debug, msg
Origin: /home/ops/estate/struct4.yml:6:7

4   connection: local
5   tasks:
6     - name: One
      ^ column 7

The source was:

Read-only / Safethe offending task
    - name: One
    ansible.builtin.debug:
    msg: one

“Conflicting action statements” is Ansible saying: this task mapping has two keys that both look like modules. Because msg is not a recognised task keyword, the only remaining interpretation is that you meant it as an action. The message names the symptom; the cause is two spaces.

The mirror image is just as common — a task keyword indented too deep, which makes it a module argument:

Read-only / Safewhen indented into the module arguments
$ ansible-playbook -i localhost, misindent.yml
fatal: [localhost]: FAILED! => {"msg": "Unsupported parameters for
(ansible_collections.ansible.builtin.plugins.action.debug) module: when.
Supported parameters include: msg, var, verbosity."}

A list that is not a list

Read-only / Safetasks given a mapping instead of a sequence
$ ansible-playbook --syntax-check -i localhost, struct2.yml
[ERROR]: A malformed block was encountered while loading tasks:
{'name': 'One', 'ansible.builtin.debug': {'msg': 'one'}}
should be a list or None but is <class '...._AnsibleTaggedDict'>
Origin: /home/ops/estate/struct2.yml:1:3

1 - name: A malformed block
  ^ column 3

This message is unusually helpful once you know how to read it: it prints the thing it found and says what shape it expected. “should be a list” means a - is missing.

Note the origin line points at the play, not at the offending task. That is normal for structural errors — the parser reports where the malformed structure was consumed, which is often several lines above where you need to edit.

The habit

Read-only / Safethe two-second check
ansible-playbook --syntax-check -i inventories/prod site.yml

--syntax-check connects to nothing. It parses the playbook and everything it statically imports, and exits 4 on a parse failure and 0 on success. Run it before every commit and wire it into CI; the Git and CI part of this course does exactly that.

What it will not do is evaluate when: conditions, resolve variables, follow include_tasks, or notice a task keyword that ended up as a module argument. It is a parser, not a linter.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A task fails with "conflicting action statements: ansible.builtin.copy, mode". What is the actual defect?

  2. Q2. Which of these are keys of the task mapping rather than arguments of the module? Select all that apply.

  3. Q3. A misindented when: that ends up as a module argument passes --syntax-check and fails only when the play reaches that task.

  4. Q4. What does the error "A malformed block was encountered while loading tasks: {...} should be a list or None" tell you?

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