Skip to main content
RunBook Academy

AnsibleXXIV · Assertions and GuardrailsAssertions and guardrails

Writing assertions operators can act on

Intermediate⏱ ~22 minansible-playbookansible-doc

What you'll learn

  • Use every option assert accepts and know which of them are required
  • Write a fail_msg that names the assumption, the actual value and the remedy
  • Predict which condition is reported when several are listed in one assert
  • Recognise the two ways an assertion fails without printing your fail_msg

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.

ansible.builtin.assert has four options. Three of them are about communication, and the module is almost entirely a communication tool: it is how the playbook tells a human being why it declined to run.

Read the contract from the installation you are actually using rather than from memory.

Read-only / Safethe whole option set
$ ansible-doc ansible.builtin.assert
OPTIONS (red indicates it is required):

 fail_msg  The customized message used for a failing assertion.
           This argument was called `msg' before Ansible 2.7, now it
           is renamed to `fail_msg' with alias `msg'.
      aliases: [msg]
      default: null
      type: str

 quiet   Set this to `true' to avoid verbose output.
      default: false
      type: bool

 success_msg  The customized message used for a successful
              assertion.
      default: null
      type: str

 that    A list of string expressions of the same form that can be
         passed to the `when' statement.
      elements: str
      type: list

that is the only required one. That is the entire API.

that — expressions in when form

The documentation is precise and the precision matters: the same form that can be passed to the when statement. That means bare Jinja expressions without {{ }}.

- name: Confirm the target before anything destructive runs
  ansible.builtin.assert:
    that:
      - deploy_env == 'production'
      - backup_age_hours < 24
      - service_state == 'stopped'
    fail_msg: 'Preconditions not met.'

A single expression can be given as a bare string rather than a list — that: deploy_env == 'production' is accepted and coerced — but writing the list form always keeps the diff clean when the second condition arrives.

Several conditions in one assert, or several asserts

Listing several expressions under one that is an AND, and evaluation short-circuits at the first false one:

Read-only / Safethree conditions, one message
$ ansible-playbook -i localhost, assert.yml
TASK [Three conditions, two of which hold] *************************************
fatal: [localhost]: FAILED! => {
  "assertion": "free_gb >= required_gb",
  "changed": false,
  "evaluated_to": false,
  "msg": "Preconditions not met."
}

The assertion key names the expression that failed, which is genuinely useful — but notice what the operator sees on the terminal first: “Task failed: Action failed: Preconditions not met.” A shared fail_msg across three conditions can only be generic, so the specific information is buried one level down in the result dictionary.

That gives a simple rule:

Group conditions under one assert when they share a remedy. Three different ways of confirming this is the production fleet all mean “you targeted the wrong inventory”, and one message covers all three.

Split into separate assert tasks when the remedies differ. “Not enough disk” and “backup is stale” are two different phone calls. Two tasks, two names in the output, two specific messages — and the task name alone tells the operator which precondition failed before they read anything else.

fail_msg — the option that does the work

An assertion that fires at 03:00 is read by someone who did not write it, under pressure, on a terminal. The message has one job: get them from “the playbook stopped” to “I know what to do” without opening the repository.

Three things belong in it.

ElementWhyExample fragment
The assumptionNames what the automation believedthis play only runs against production web servers
The actual valueTurns a rule into an observationdeploy_env is 'staging'
The remedyThe reason they stop reading and start fixingre-run with -i inventories/production/hosts

Put together:

- name: Confirm the target environment
  ansible.builtin.assert:
    that:
      - deploy_env == 'production'
    fail_msg: >-
      Refusing to run: this play changes production web servers, but
      deploy_env is '{{ deploy_env }}' and the target is
      {{ inventory_hostname }}. Re-run against
      inventories/production/hosts, or set -e deploy_env=production if
      you are certain.

Compare that with what you get from the same assertion with no fail_msg at all:

Configuration changethe default, for contrast
$ ansible-playbook -i inv.ini deploy.yml
fatal: [web01.example.com]: FAILED! => {
  "assertion": "deploy_env == 'production'",
  "changed": false,
  "evaluated_to": false,
  "msg": "Assertion failed"
}

Illustrative output

Assertion failed is true, and it is the difference between a three-minute stop and a thirty-minute one.

success_msg and quiet

success_msg prints when the assertion holds. Use it where the fact of the check passing is itself evidence somebody wants — a pre-production gate whose output is pasted into a change record, for instance.

quiet: true suppresses the per-assertion detail on success. Both behaviours in one run:

Read-only / Safesuccess_msg and quiet
$ ansible-playbook -i localhost, assert2.yml
TASK [Confirm the target environment] ******************************************
ok: [localhost] => {
  "changed": false,
  "msg": "Target environment confirmed as production."
}

TASK [The same assertion, quiet] ***********************************************
ok: [localhost]

The two failures that do not print your fail_msg

Both are common and both are confusing the first time.

An undefined variable inside that. The conditional cannot be evaluated at all, so the module never reaches the point of deciding the assertion is false:

Read-only / Safeundefined in a conditional
$ ansible-playbook -i localhost, assert3.yml
TASK [A bare undefined variable inside that] ***********************************
[ERROR]: Task failed: Error while evaluating conditional: 'never_defined_anywhere' is undefined
fatal: [localhost]: FAILED! => {"changed": false, "msg": "Task failed: Error while evaluating conditional: 'never_defined_anywhere' is undefined"}

The play still stops, which is the safe outcome — but the carefully written fail_msg is nowhere on screen. Guard the existence of the variable before you guard its value:

- name: Confirm the required inputs were supplied
  ansible.builtin.assert:
    that:
      - deploy_env is defined
      - target_version is defined
    fail_msg: >-
      Required variables missing. This play needs deploy_env and
      target_version; supply them with -e or from group_vars.

- name: Confirm the inputs are the ones this play is for
  ansible.builtin.assert:
    that:
      - deploy_env == 'production'
    fail_msg: "deploy_env is '{{ deploy_env }}', not production."

A type mismatch that reads as correct. YAML makes this easy, because a quoted "8080" is a string and Jinja does not compare it equal to the integer 8080:

Read-only / Safethe comparison that looks true and is false
$ ansible-playbook -i localhost, assert4.yml
TASK [A string comparison against a number-looking string] **********************
fatal: [localhost]: FAILED! => {
  "assertion": "app_port == 8080",
  "changed": false,
  "evaluated_to": false,
  "msg": "app_port is 8080 (str), not the integer 8080"
}

Here the fail_msg does print, and it prints because it was written to include {{ app_port | type_debug }}. Without that filter the message would have read “app_port is 8080, not the integer 8080”, which is the single most annoying error message in this course. When an assertion compares a value that came from inventory, a vars_files entry or -e, put | type_debug or | int where it belongs.

Knowledge check

Knowledge check · 4 questions

  1. Q1. One assert lists three conditions and the second one is false. What appears in the assertion key of the result?

  2. Q2. Which of these are true of quiet: true on an assert? Select all that apply.

  3. Q3. Writing a condition as "{{ deploy_env == 'production' }}" inside that works today but emits a deprecation warning and stops working in a future ansible-core release.

  4. Q4. A guard asserts app_port == 8080. The inventory sets app_port: "8080". What happens, and what is the fix worth making?

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