AnsibleXXIV · Assertions and GuardrailsAssertions and guardrails
Writing assertions operators can act on
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
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.
$ ansible-doc ansible.builtin.assertOPTIONS (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: listthat 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:
$ ansible-playbook -i localhost, assert.ymlTASK [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.
| Element | Why | Example fragment |
|---|---|---|
| The assumption | Names what the automation believed | this play only runs against production web servers |
| The actual value | Turns a rule into an observation | deploy_env is 'staging' |
| The remedy | The reason they stop reading and start fixing | re-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:
$ ansible-playbook -i inv.ini deploy.ymlfatal: [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:
$ ansible-playbook -i localhost, assert2.ymlTASK [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:
$ ansible-playbook -i localhost, assert3.ymlTASK [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:
$ ansible-playbook -i localhost, assert4.ymlTASK [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
Q1. One assert lists three conditions and the second one is false. What appears in the assertion key of the result?
Q2. Which of these are true of quiet: true on an assert? Select all that apply.
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.
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.