Skip to main content
RunBook Academy

Git, CI/CD & GitOpsLI · Ansible CISyntaxCheck

Ansible playbook syntax check — what --syntax-check proves and what it does not

Intermediate⏱ ~22 mingitansibleansible-lint

What you'll learn

  • Run ansible-playbook --syntax-check and explain what it does at the parser level
  • Identify the four classes of mistake --syntax-check catches: YAML parse, Jinja parse, module resolution, variable resolution
  • Recognise what --syntax-check does not catch: task success, idempotency, system state, network reachability
  • Place --syntax-check as the post-lint, pre-runtime gate in the Ansible CI pipeline

Prerequisites

Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x

Not yet marked complete on this device.

After yamllint and ansible-lint, the next gate is ansible-playbook --syntax-check. It is the first gate that loads the playbook as Ansible loads it: the same parser, the same variable resolver, the same collection cache. Where the linters see text, --syntax-check sees a playbook. Where the linters report static rules, --syntax-check reports whether the playbook can be parsed and resolved at all.

What the command does

The canonical CI invocation is:

ansible-playbook --syntax-check playbook.yml

The flag tells Ansible to load the playbook, run its parser, resolve variable references against the inventory and variable files, resolve module references against the local collection cache, and evaluate Jinja expressions to confirm they parse. If any of those steps fail, the command exits non-zero with a diagnostic. If they all succeed, the command exits zero without making any network connection or running any task.

The flag does not require --check, does not connect to managed hosts, and does not write to the working tree. It is a read operation against the playbook file and the local cache.

flowchart LR
    A[playbook.yml] --> B[ansible-playbook --syntax-check]
    B --> C[Parse YAML]
    C --> D[Resolve Jinja]
    D --> E[Resolve variables]
    E --> F[Resolve modules from collections cache]
    F --> G{All resolved?}
    G -->|yes| H[Exit 0]
    G -->|no| I[Print diagnostic, exit 1]

The output is short and specific. A YAML parse failure reports the file, line, and column. A Jinja parse failure reports the expression and the error from the Jinja parser. A missing module reports the FQCN the loader could not find. An undefined variable reports the variable name and the play or task that referenced it.

The four classes of mistake it catches

--syntax-check is scoped to four claims about the playbook, and the production pipeline relies on it for exactly those four:

  • YAML parse errors. Tabs instead of spaces, mismatched indentation, unterminated flow mappings, wrong document markers. yamllint catches a subset of these; --syntax-check catches whatever yamllint missed and whatever Ansible’s own parser rejects.
  • Jinja parse errors. An expression that calls a filter with the wrong argument count, a {% set %} that closes incorrectly, a { } block that contains a Python-style statement instead of an expression. The Jinja parser runs as part of playbook parsing.
  • Module resolution errors. A module FQCN that is not in the installed collections. A bare name that the search path cannot resolve. A module that exists but has been removed from the installed collection version. The loader raises this at parse time, not at task time.
  • Variable resolution errors. A vars: reference that points to a name that is never defined in any scope (play vars, host vars, group vars, role defaults, role vars, extra vars, set_fact). The resolver reports the missing name and the location of the reference.

These four classes are the cheapest bugs to find because they are found without contacting a managed host. Every playbook that passes --syntax-check has a clear path from file to running task; every playbook that fails has a specific line, expression, or reference that needs to be fixed.

What —syntax-check does not catch

The gate is deliberately scoped. The classes of mistake it deliberately leaves to other gates:

  • Task success. A task that uses the right module and the right arguments can still fail at runtime: the package does not exist on the target distribution, the service unit is missing, the file path is on a read-only mount. --syntax-check proves the task is parseable, not that it would succeed.
  • Idempotency. A task that uses ansible.builtin.shell with changed_when: false will pass --syntax-check even though it will report no change on every run, including the ones that should have changed something. Idempotency is a runtime property of the change detection logic.
  • Runtime behaviour. That the package installation will be atomic, that the configuration file will be templated correctly, that the service will restart. Runtime behaviour is what --check --diff and Molecule exist to observe.
  • Network reachability and authentication. That the managed host is reachable, that the SSH key is authorised, that become will succeed. --syntax-check does not open a network connection; it is impossible for it to fail on these grounds and equally impossible for it to validate them.
  • Inventory contents. A play with hosts: webservers passes --syntax-check even if the inventory contains no host in the webservers group. The parser trusts the inventory; the runtime is where the missing host becomes a problem.

These are the gaps that the next gates in the pipeline exist to close. --syntax-check is not a substitute for --check --diff or Molecule; it is the gate that runs in front of them and removes the parse-time failures that would otherwise waste a runtime slot.

Placement in the pipeline

--syntax-check is the third gate in the canonical order:

  1. yamllint - YAML shape (milliseconds, no host).
  2. ansible-lint - Ansible shape (seconds, no host).
  3. ansible-galaxy collection install -r collections/requirements.yml - install the collections the playbook names.
  4. ansible-playbook --syntax-check playbook.yml - parse the playbook end-to-end against the installed cache.
  5. … runtime gates: ansible-playbook --check --diff, molecule converge, molecule verify.

The ordering matters because each gate removes a class of failure that would cause the next gate to produce a less-useful error. A YAML parse failure caught by yamllint is reported as a file:line; the same failure caught by --syntax-check is reported as a generic playbook-load failure. Failing early produces a better error.

Reading the output

The exit status is the gate. The stdout is the diagnostic. Three patterns are common:

  • YAML errors - reported with a ERROR! prefix and a path like The error appears to be in './roles/web/tasks/main.yml': line 5, column 7. The file path, line, and column are accurate; fix the indentation or structure on that line.
  • Jinja errors - reported with a message from the Jinja parser, often naming the unexpected token. Common causes: a filter called with the wrong number of arguments, a {% raw %} block that does not close, a { } that contains an assignment.
  • Missing module or variable - reported as ERROR! couldn't resolve module/action 'community.general.foo' or ERROR! The task includes an option with an undefined variable. The message names what could not be resolved and where it was referenced.

A --syntax-check failure that is not one of these three patterns usually indicates a deeper issue: an import_tasks pointing at a missing file, an include_vars referencing a missing variable file, or a vars_files path that does not exist. The fix is the path; the diagnostic is precise.

Production discipline

  1. --syntax-check runs in the same CI job as ansible-galaxy collection install. The gate sees the same collections as the runtime will see.
  2. The exit code is the gate. A passing --syntax-check exits 0; the pipeline does not interpret warnings as success or warnings as failure, only the exit code.
  3. --syntax-check runs against every playbook in the repository, not a sample. A monorepo of twenty playbooks needs twenty --syntax-check calls; CI is the right place to enumerate them.
  4. The --syntax-check job is followed by --check --diff, not by molecule directly. The check-and-diff gate is what surfaces the planned changes that the review needs to see.
  5. --syntax-check is not a substitute for running the playbook. A green badge on this gate is a parse-time claim, not a runtime claim.

Cross-course references

  • Ansible for Production Sysadmins - Part XXV (CheckDiff) covers the --check and --diff runtime gate that sits after this gate.
  • Ansible for Production Sysadmins - Part XXXVIII (GitCI) is the full pipeline; this lesson is the parse-time stage.
  • This course, Part L (TerraformCI) - lesson git-cicd-gitops-l-02-fmt-and-validate is the Terraform analogue; the gate order and the scope-vs-claim pattern are parallel.
  • This course, Part XLIX (InfrastructureCI) - lesson git-cicd-gitops-xlix-05-test-and-validate is the general framing of test-and-validate; this lesson is the Ansible instantiation.

Quiz

Knowledge check · 4 questions

  1. Q1. A playbook uses `community.general.parted` but the CI runner has not run `ansible-galaxy collection install -r collections/requirements.yml`. What is the most likely behaviour of `ansible-playbook --syntax-check`?

  2. Q2. A playbook that passes `ansible-playbook --syntax-check` is not guaranteed to apply successfully against a managed host.

  3. Q3. Name the four classes of mistake that `ansible-playbook --syntax-check` is scoped to catch.

  4. Q4. Diagnose why a `--syntax-check` gate passed but a CI apply still failed, and identify what the gate is correctly scoped to do.

    A team runs yamllint, ansible-lint, and ansible-playbook --syntax-check as their full pre-apply gate suite. A contributor adds a task that uses ansible.builtin.yum to install a package named `internal-tool`, which is only available in the team's internal repository. The task is well-formed, the module is correctly FQCN'd, the variable references resolve. The three static gates pass. The PR is merged. The CI apply stage runs against a fresh container, attempts to install the package, and fails because the container does not have the internal repository configured. The team concludes the static gates are unreliable.

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