| Tested on | Rocky Linux 10.2 (Red Quartz) |
|---|---|
| Package | ansible-core 2.16.16 |
| Applies to | RHEL, Rocky Linux, AlmaLinux, Oracle Linux, CentOS Stream, Fedora |
| Privilege | sudo or root |
| Scope | Handle task failures with block, rescue, and always sections, plus failed_when and changed_when for custom pass and change rules. |
Ansible error handling is how you decide what counts as failure, what counts as change, and what runs next when something goes wrong. Blocks group related work; rescue runs recovery when a block task fails; always runs cleanup either way. failed_when and changed_when fine-tune task status when module defaults lie.
This guide covers block/rescue/always, ansible_failed_task / ansible_failed_result, custom failure and changed logic, register with command output, unreachable hosts, and meta: clear_host_errors. It assumes you can run a basic playbook from your first playbook tutorial and understand when conditionals at a high level. For operator syntax inside expressions, see operators; for handler notify timing, see handlers only where changed status matters.
| Keyword | Purpose |
|---|---|
failed_when |
Decide when a task should be marked failed |
changed_when |
Decide when a task should be marked changed |
ignore_errors |
Continue after a failed task |
block / rescue |
Run recovery tasks after failure |
always |
Run cleanup tasks regardless of result |
meta: clear_host_errors |
Allow previously unreachable hosts to be tried again |
What is Error Handling in Ansible?
Error handling controls three things in a playbook:
- What happens when a task fails
- When a task should be treated as failed even if the module returned success (or the reverse)
- Which recovery or cleanup tasks run after success or failure
Ansible stops remaining tasks on a host after a normal failure. Error-handling keywords let you recover gracefully, continue non-critical failures, or reset host state for another connection attempt.
Why Blocks, Rescue and Status Conditions Matter
| Problem | Tool |
|---|---|
Messy playbooks with scattered ignore_errors |
block / rescue / always |
command returns rc 1 but that is expected |
failed_when: result.rc > 1 |
Probe command should not mark changed every run |
changed_when: false |
| Need rollback after a failed deploy step | rescue block |
| Must log or clean temp files either way | always block |
| Host marked unreachable mid-play | meta: clear_host_errors |
Without these controls, teams either stop the whole play on benign exit codes or hide real failures with blanket ignore_errors. When recovery still fails, work through Ansible troubleshooting and debug playbook techniques before masking errors.
Ansible Task Results: ok, changed, failed and unreachable
Each task line per host shows a status. PLAY RECAP totals them at the end.
| Status | Meaning |
|---|---|
ok |
Task ran and succeeded; no change recorded (or change not applicable) |
changed |
Task succeeded and Ansible recorded a change on the host |
failed |
Task did not complete successfully on that host |
unreachable |
Ansible could not connect or execute on the host |
skipped |
Task not run (when false, tags, or guards) |
Recap also shows rescued=1 when a block failure was handled by rescue, and ignored=1 when ignore_errors allowed the play to continue.
unreachable is not the same as failed. A failed task reached the host; unreachable means Ansible could not run the module there. rescue handles failed block tasks—not unreachable hosts or syntax errors (Ansible blocks documentation).
What are Blocks in Ansible?
A block groups tasks so they share optional task keywords (when, become, tags, and others) and optional rescue / always sections.
- name: App deploy unit
block:
- name: Main step
ansible.builtin.debug:
msg: "Inside block"
rescue:
- name: Recovery
ansible.builtin.debug:
msg: "On failure"
always:
- name: Cleanup
ansible.builtin.debug:
msg: "Always runs"Blocks are play tasks—they are not a separate YAML document. Official reference: Blocks.
Use block to Group Related Tasks
Use a block when several tasks share a condition or belong to one logical step.
- name: App deploy block
when: deploy_app
block:
- name: Create marker
ansible.builtin.copy:
content: "deployed\n"
dest: /tmp/blocks-demo-marker.txt
mode: "0644"
- name: Report deploy
ansible.builtin.debug:
msg: "Block tasks ran under shared when"ansible-playbook block-group.ymlSample output:
TASK [Create marker] ***********************************************************
changed: [localhost]
TASK [Report deploy] ***********************************************************
ok: [localhost] => {
"msg": "Block tasks ran under shared when"
}
PLAY RECAP *********************************************************************
localhost : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0The when on the block applies to every task inside it—cleaner than repeating the same condition on five tasks.
Use rescue to Recover from Failed Tasks
rescue handles task failures on reachable hosts only. When Ansible marks a host UNREACHABLE (SSH timeout, wrong key, firewall), no module ran on that host—so rescue does not run. That is a connectivity problem, not a block failure. Fix inventory, SSH, and firewalls first; use the troubleshooting workflow above for SSH and reachability checks.
rescue defines tasks that run when any task in the parent block returns failed on that host. When that happens, Ansible stops running remaining tasks in the block and jumps straight to rescue. Ansible docs note rescue is triggered after a task returns a failed state—not for unreachable hosts, undefined variables, or playbook syntax errors.
Use always for Cleanup Tasks
always runs after the block and rescue sections complete—whether the block succeeded, failed and was rescued, or failed without rescue. Use it for cleanup, logging, and final status messages.
Invalid task definitions and unreachable hosts are exceptions: they do not trigger rescue or always for that block (Ansible blocks documentation).
block vs rescue vs always
| Section | Runs when | Typical use |
|---|---|---|
block |
Normal task flow | Main operation |
rescue |
A task inside block fails |
Recovery or rollback |
always |
Success or failure | Cleanup, logging, final status |
Full flow demo:
---
- name: Block rescue always demo
hosts: lab
gather_facts: false
tasks:
- name: Deploy with recovery
block:
- name: Create app directory
ansible.builtin.file:
path: /tmp/blocks-demo-app
state: directory
mode: "0755"
- name: Intentional failure inside block
ansible.builtin.command: /bin/false
rescue:
- name: Recovery task
ansible.builtin.debug:
msg: "Rescue ran after block failure"
always:
- name: Cleanup status
ansible.builtin.debug:
msg: "Always runs after block or rescue"ansible-playbook block-rescue-always.ymlSample output:
TASK [Create app directory] ****************************************************
changed: [localhost]
TASK [Intentional failure inside block] ****************************************
fatal: [localhost]: FAILED! => {"changed": true, "cmd": ["/bin/false"], ...}
TASK [Recovery task] ***********************************************************
ok: [localhost] => {
"msg": "Rescue ran after block failure"
}
TASK [Cleanup status] **********************************************************
ok: [localhost] => {
"msg": "Always runs after block or rescue"
}
PLAY RECAP *********************************************************************
localhost : ok=3 changed=1 unreachable=0 failed=0 skipped=0 rescued=1 ignored=0rescued=1 in recap means rescue handled the block failure. When rescue succeeds, Ansible treats the failure as handled for play continuation—the host is not counted as failed, but the original failure still appears in task output and statistics. Remaining play tasks on that host continue after the block/rescue/always unit finishes.
Custom Failure Conditions with failed_when
By default, non-zero rc from command/shell marks the task failed. failed_when overrides that with a Jinja2 expression—without {{ }} braces (same rule as when; see conditionals).
The task fails when the expression is true.
Multiple failed_when conditions use AND
When you write failed_when as a YAML list, Ansible joins the conditions with implicit and:
failed_when:
- result.rc != 0
- "'ERROR' in result.stderr"The task fails only when both conditions are true.
If the task should fail when either condition is true, use one expression with or:
failed_when: result.rc != 0 or 'ERROR' in result.stderrCustom Changed Status with changed_when
changed_when overrides whether Ansible records changed. That affects idempotency output and whether notify queues handlers. For full idempotency strategy, see idempotency—here we focus on status control only.
The same list rule applies to changed_when: a YAML list means all conditions must be true (implicit and). Use one expression with or when any condition should mark the task changed:
changed_when: result.rc == 0 and ('UPDATED' in result.stdout or 'MODIFIED' in result.stdout)Use failed_when and changed_when with command Output
Classic pattern: grep returns rc 1 when a pattern is missing—that is not always a real error.
- name: Search for pattern that may be absent
ansible.builtin.command: grep -q BLOCKS_DEMO_PATTERN /etc/hosts
register: grep_result
failed_when: grep_result.rc > 1
changed_when: falseansible-playbook failed-when-rc.ymlSample output:
TASK [Search for pattern that may be absent] ***********************************
ok: [localhost]
TASK [Continue when pattern is missing] ****************************************
ok: [localhost] => {
"msg": "grep rc 1 is not a failure here"
}
PLAY RECAP *********************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0rc 1 (not found) is treated as success; only rc greater than 1 would fail the task.
Fail when specific text appears in stderr:
- name: Run command that writes to stderr
ansible.builtin.shell: echo "permission denied" >&2; exit 1
register: cmd_out
failed_when: "'permission denied' in cmd_out.stderr"
changed_when: falseansible-playbook failed-when-stderr.ymlSample output:
TASK [Run command that writes to stderr] ***************************************
fatal: [localhost]: FAILED! => {..., "failed_when_result": true, "stderr": "permission denied", ...}
PLAY RECAP *********************************************************************
localhost : ok=0 changed=0 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0failed_when_result: true in the output confirms your expression triggered the failure.
Mark a probe as unchanged:
- name: Probe without marking changed
ansible.builtin.command: echo "status check"
register: probe
changed_when: falseansible-playbook changed-when.ymlSample output:
TASK [Probe without marking changed] *******************************************
ok: [localhost]
TASK [Show probe result] *******************************************************
ok: [localhost] => {
"probe.changed": false
}For deeper command vs shell trade-offs, see command vs shell vs raw.
Use register with failed_when and changed_when
Register the task result first, then reference fields in failed_when or changed_when:
- name: Run validation command
ansible.builtin.command: nginx -t
register: nginx_test
changed_when: false
failed_when: nginx_test.rc != 0- name: Treat specific stdout as changed
ansible.builtin.command: /opt/app/status.sh
register: app_status
changed_when: "'UPDATED' in app_status.stdout"
failed_when: app_status.rc != 0 and app_status.rc != 2Registered fields (rc, stdout, stderr, failed, and module-specific keys) are documented in register variables. Combine with block/rescue when validation failure should trigger rollback tasks.
Recovery Workflow with block and rescue
Typical deploy pattern:
- block — apply config, run migration, enable service
- rescue — restore backup config, disable partial state, alert operator
- always — write deploy log line, remove temp files
The block-rescue-always.yml demo above is the minimal version: main work fails, rescue reports recovery, always prints cleanup. In production, rescue tasks should undo or isolate the failed change—not only print debug text.
Inspect Failed Task Details in rescue
Inside a rescue section, Ansible exposes details about the failed block task (Ansible blocks documentation):
| Variable | Meaning |
|---|---|
ansible_failed_task |
The task object that triggered rescue |
ansible_failed_result |
The failed task result, similar to registered output |
Ansible 2.14+ also propagates these variables from an inner block to an outer rescue when blocks are nested.
- name: Deploy with failure details
block:
- name: Validate app config
ansible.builtin.command: /opt/app/validate-config
rescue:
- name: Show failed task name
ansible.builtin.debug:
msg: "failed task={{ ansible_failed_task.name }}"
- name: Show failed rc
ansible.builtin.debug:
msg: "rc={{ ansible_failed_result.rc | default('n/a') }}"ansible-playbook failed-task-vars.ymlSample output:
TASK [Validate app config] *****************************************************
fatal: [localhost]: FAILED! => {..., "rc": 2, "msg": "[Errno 2] No such file or directory: b'/opt/app/validate-config'", ...}
TASK [Show failed task name] ***************************************************
ok: [localhost] => {
"msg": "failed task=Validate app config"
}
TASK [Show failed rc] **********************************************************
ok: [localhost] => {
"msg": "rc=2"
}
PLAY RECAP *********************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=1 ignored=0Use ansible_failed_task and ansible_failed_result for logging, notifications, or conditional rollback. Not every failure exposes rc—use default filters when a field may be missing.
Cleanup Workflow with always
always suits work that must run whether deploy succeeded or failed:
- Delete
/tmpstaging files - Emit structured log event
- Update external ticket status
always runs even when rescue itself contains a failing task (unless the failure is outside the block/rescue/always structure). Keep always tasks short and resilient.
Handling Unreachable Hosts
When Ansible cannot connect, the host is marked unreachable for the rest of the play unless cleared. Unreachable hosts do not trigger rescue—there was no failed module result on the host, only a connection failure.
Symptoms in recap: unreachable=1 (or higher), not failed=1 for that connectivity issue.
Official docs distinguish resetting unreachable hosts from handling task failure inside a block (Handling errors with blocks).
Recover Hosts with meta: clear_host_errors
ansible.builtin.meta: clear_host_errors clears the unreachable marker for hosts in the current play so later tasks can attempt connection again. It does not repair SSH keys, firewalls, or down networks.
Inventory with one reachable and one unreachable host:
[lab]
localhost ansible_connection=local
unreachable1 ansible_host=192.0.2.1 ansible_connection=ssh ansible_ssh_common_args='-o ConnectTimeout=3'---
- name: clear_host_errors demo
hosts: lab
gather_facts: false
tasks:
- name: Ping all hosts
ansible.builtin.ping:
- name: Clear unreachable markers
ansible.builtin.meta: clear_host_errors
- name: Ping again after clear
ansible.builtin.ping:ansible-playbook -i inventory-unreach clear-host-errors.ymlSample output:
TASK [Ping all hosts] **********************************************************
ok: [localhost]
fatal: [unreachable1]: UNREACHABLE! => {"changed": false, "msg": "Failed to connect to the host via ssh: ssh: connect to host 192.0.2.1 port 22: Connection timed out", "unreachable": true}
TASK [Clear unreachable markers] ***********************************************
TASK [Ping again after clear] **************************************************
ok: [localhost]
fatal: [unreachable1]: UNREACHABLE! => {"changed": false, "msg": "Failed to connect to the host via ssh: ...", "unreachable": true}
PLAY RECAP *********************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
unreachable1 : ok=0 changed=0 unreachable=2 failed=0 skipped=0 rescued=0 ignored=0localhost keeps running; unreachable1 is tried again after clear but still times out. Use this when a transient network blip might recover—not as a substitute for fixing connectivity.
Reference: Resetting unreachable hosts.
block/rescue vs ignore_errors
block / rescue |
ignore_errors: true |
|
|---|---|---|
| Recovery tasks | Dedicated rescue section |
None—you must add separate tasks with when |
| Play recap | rescued=1 when rescue runs |
ignored=1 on the failed task |
| Structure | Clear main vs recovery vs cleanup | One-line continue |
| Best for | Deploy rollback, structured recovery | Optional checks that may fail |
- name: Non-critical failure
ansible.builtin.command: /bin/false
ignore_errors: trueansible-playbook ignore-errors.ymlSample output:
TASK [Non-critical failure] ****************************************************
fatal: [localhost]: FAILED! => {"changed": true, "cmd": ["/bin/false"], ...}
...ignoring
TASK [Next task still runs] ****************************************************
ok: [localhost] => {
"msg": "Continued after ignore_errors"
}
PLAY RECAP *********************************************************************
localhost : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=1Official ignore_errors docs: it applies when a task runs and returns failed—not for undefined variables, connection failures, or syntax errors.
failed_when vs ignore_errors
failed_when— change whether the task counts as failed (precision)ignore_errors— task may still fail, but the play continues (breadth)
Prefer failed_when when rc or output defines success. Use ignore_errors only for truly optional steps. Hiding every failure with ignore_errors trains operators to ignore red output.
changed_when vs handlers
Handlers notify only when the task final status is changed. Setting changed_when: false on a config task prevents handler restarts even if the module default would mark changed.
Setting changed_when: true on a debug or command task can spuriously notify handlers—avoid that pattern. Details of flush timing and notify live in handlers; not repeated here.
Run Notified Handlers from rescue
If a task changed config and notified a handler, but a later task in the same block fails, queued handlers may not run at the default flush point. When handlers must run before recovery continues, flush them inside rescue:
---
- name: Flush handlers from rescue
hosts: lab
gather_facts: false
handlers:
- name: reload app
ansible.builtin.debug:
msg: "Handler ran from flush in rescue"
tasks:
- name: Deploy with handler flush on failure
block:
- name: Change config and notify handler
ansible.builtin.copy:
content: "demo\n"
dest: /tmp/blocks-handler-demo.conf
mode: "0644"
notify: reload app
- name: Fail later
ansible.builtin.command: /bin/false
rescue:
- name: Run queued handlers before rollback
ansible.builtin.meta: flush_handlers
- name: Report rescue
ansible.builtin.debug:
msg: "Rescue after handler flush"ansible-playbook flush-handlers-rescue.ymlSample output:
TASK [Change config and notify handler] ****************************************
changed: [localhost]
TASK [Fail later] **************************************************************
fatal: [localhost]: FAILED! => {"changed": true, "cmd": ["/bin/false"], ...}
RUNNING HANDLER [reload app] ***************************************************
ok: [localhost] => {
"msg": "Handler ran from flush in rescue"
}
TASK [Report rescue] ***********************************************************
ok: [localhost] => {
"msg": "Rescue after handler flush"
}
PLAY RECAP *********************************************************************
localhost : ok=3 changed=1 unreachable=0 failed=0 skipped=0 rescued=1 ignored=0Use meta: flush_handlers in rescue only when flushing all queued handlers on that host is safe. Official docs describe this pattern under controlling when handlers run.
Common Error Handling Examples
Run recovery tasks when a command fails
Use block/rescue around the risky command; put rollback in rescue (see block-rescue-always.yml above).
Clean temporary files with always
always:
- name: Remove staging dir
ansible.builtin.file:
path: /tmp/deploy-staging
state: absentFail only when specific text appears in stderr
See failed-when-stderr.yml—failed_when: "'permission denied' in cmd_out.stderr".
Mark a command task unchanged
See changed-when.yml—changed_when: false on probes and status checks.
Restart service only when validation succeeds
nginx -t -c %s expects %s to be a full nginx main configuration file. Use validate: when the destination is that main file—not a single conf.d snippet:
- name: Deploy full nginx config after validation
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
validate: nginx -t -c %s
notify: reload nginxAnsible writes the rendered template to a temporary path, runs nginx -t -c against that temp file, and only replaces /etc/nginx/nginx.conf when validation succeeds. Validation failure fails the task before notify fires. Use validate: on template/copy where the check matches the file shape (handlers guide covers restart timing).
Continue after a non-critical task failure
See ignore-errors.yml for optional steps. Prefer narrow failed_when over blanket ignore when you can define success.
Retry unreachable hosts with meta: clear_host_errors
See clear-host-errors.yml—clear between task groups when a transient outage might recover. For task-level retries with delay, use the loops article (until, retries, delay)—not duplicated here.
Common Mistakes with Blocks and Status Conditions
| Mistake | Why it hurts | Fix |
|---|---|---|
Expecting rescue for unreachable hosts |
Rescue needs a failed task result on the host | Fix connectivity or use clear_host_errors; do not expect rescue |
{{ }} inside failed_when |
Template syntax error or wrong evaluation | Write probe.rc != 0 without braces |
Blanket ignore_errors: true |
Hides real production failures | Use failed_when or block/rescue |
changed_when: false on every task blindly |
Handlers never run; false sense of idempotency | Only suppress change when truly no drift |
Duplicate handler notify via changed_when: true |
Restarts every play | Let modules report change honestly |
rescue with no rollback |
Failure is logged but system left broken | Rescue should undo or isolate |
notify without understanding changed_when |
Service never restarts or restarts too often | Match changed logic to real config drift |
Expecting list conditions in failed_when to mean OR |
YAML lists are implicit AND | Use one expression with or |
Using ansible_failed_result.rc without default |
Some failures omit fields you expect | Pipe the field through default('n/a') |
Assuming always runs for unreachable hosts |
Unreachable does not trigger block error handling | Handle connectivity with clear_host_errors or fix SSH |
| Expecting handlers after a failed block | Failure can prevent queued handlers from running | Use meta: flush_handlers in rescue only when safe |
Best Practices for Ansible Error Handling
- Use
block/rescue/alwaysfor deploy units—not single tasks wrapped “just in case.” - Put shared
whenorbecomeon the block, not repeated per task. - Register command output; encode success in
failed_when, not silent ignore. - Validate configs (
validate:ontemplate/copy) before notifying restarts. - Distinguish unreachable from failed in runbooks and monitoring.
- Test failure paths: run plays with bad config on a lab host and confirm rescue/always.
- Keep rescue idempotent where possible—rerun should not double-rollback.
Recommended Error Handling Style for Real Playbooks
For maintainable Ansible playbooks:
- Group deploy steps in
block; rollback inrescue; log/cleanup inalways. - Use
failed_whenoncommand/shellwithregister—do not default toignore_errors. - Use
changed_when: falseon probes; letcopy/templatereport real change. - Log failures with
ansible_failed_task/ansible_failed_resultin rescue when rollback depends on which step broke. - Notify handlers only from tasks that truly changed configuration; flush in rescue only when safe.
- Use FQCN modules (
ansible.builtin.command) in new playbooks. - Know that
rescuedoes not save you from unreachable hosts—fix inventory and SSH first.
Summary
Ansible error handling combines structural tools (block, rescue, always) with status keywords (failed_when, changed_when, ignore_errors) and host recovery (meta: clear_host_errors). Blocks group work; rescue recovers from failed tasks and exposes ansible_failed_task / ansible_failed_result; always cleans up when block error handling applies. List-form failed_when and changed_when use implicit AND—use or in one expression when you need either condition. Unreachable hosts are a connection problem, not a rescue problem—clear and retry only when that matches your outage model.

