Error Handling in Ansible
How Ansible detects task failure, and the mechanisms available to control, suppress, and recover from it.
How Ansible determines failure
A task fails when the module return value has failed: true, or when a module exits with a non-zero return code and Ansible's default failure detection applies. For command and shell, a non-zero exit code causes failure unless overridden. Unreachable hosts (SSH/WinRM connection failure) are tracked separately from failed tasks and behave differently in playbook execution.
By default, once a host fails a task, that host is removed from the remaining play; other hosts continue. This is per-host, not global.
ignore_errors
Suppresses task failure so the play continues on that host. The task is still marked as failed in the results and in ansible-playbook output/summary, but execution proceeds.
- name: Attempt to stop a service that may not exist
ansible.builtin.service:
name: legacy-app
state: stopped
ignore_errors: true
ignore_errors does not suppress unreachable errors. Use ignore_unreachable for that (added in Ansible 2.10 for tasks; play-level behavior differs).
failed_when
Overrides what counts as failure, independent of return code. Takes a conditional expression evaluated against registered output.
- name: Run a check script
ansible.builtin.command: /opt/scripts/check.sh
register: result
failed_when: "'CRITICAL' in result.stdout"
Multiple conditions can be combined with a list (ANDed) or explicit boolean logic:
- name: Validate exit code and output
ansible.builtin.command: /opt/scripts/check.sh
register: result
failed_when:
- result.rc != 0
- "'expected' not in result.stdout"
Setting failed_when: false unconditionally marks a task as never failed, which is a common pattern when only rc or stdout is being harvested for a later comparison.
changed_when
Not failure handling per se, but frequently paired with failed_when because command/shell tasks report changed: true by default. Use it to make status reporting accurate:
- name: Check disk usage
ansible.builtin.command: df -h /
register: disk
changed_when: false
failed_when: false
block, rescue, and always
The primary structured error-handling construct. Modeled on try/except/finally.
- block — tasks executed in sequence.
- rescue — runs only if a task in
blockfails; the failure is then cleared for that host and the play continues normally afterward. - always — runs regardless of whether
blocksucceeded, failed, or was rescued.
- name: Deploy with rollback
block:
- name: Update application code
ansible.builtin.git:
repo: https://example.com/app.git
dest: /srv/app
version: "{{ deploy_version }}"
- name: Restart application
ansible.builtin.systemd:
name: app
state: restarted
rescue:
- name: Roll back to previous version
ansible.builtin.git:
repo: https://example.com/app.git
dest: /srv/app
version: "{{ previous_version }}"
- name: Restart application after rollback
ansible.builtin.systemd:
name: app
state: restarted
- name: Re-raise as a fatal failure
ansible.builtin.fail:
msg: "Deployment failed, rolled back to {{ previous_version }}"
always:
- name: Send deployment notification
ansible.builtin.uri:
url: "{{ notify_webhook }}"
method: POST
body_format: json
body:
status: "{{ ansible_failed_task is not defined }}"
ansible_failed_task and ansible_failed_result
Inside a rescue block, two facts are set for that host: ansible_failed_task (the failed task's data) and ansible_failed_result (its full result dictionary). Useful for logging or conditional rollback logic without re-registering the failing task.
- name: Log which task failed
ansible.builtin.debug:
msg: "Task '{{ ansible_failed_task.name }}' failed: {{ ansible_failed_result.msg }}"
Explicit failure: fail and assert
ansible.builtin.fail forces a task failure with a custom message, typically gated by a condition:
- name: Abort if required variable is missing
ansible.builtin.fail:
msg: "db_host must be defined"
when: db_host is not defined
ansible.builtin.assert is the same idea but declarative, supporting multiple conditions and separate success/failure messages:
- name: Validate inputs
ansible.builtin.assert:
that:
- db_host is defined
- db_port | int > 0
- db_port | int < 65536
fail_msg: "Invalid database connection parameters"
success_msg: "Database parameters look valid"
Host-level and run-level controls
any_errors_fatal
By default a failure on one host does not stop other hosts. Setting any_errors_fatal: true on a play (or block) causes any host failure to immediately stop the entire run across all hosts, after currently running tasks finish.
- hosts: all
any_errors_fatal: true
tasks:
- name: Critical shared step
ansible.builtin.command: /opt/provision.sh
max_fail_percentage
Allows a play to tolerate a percentage of host failures before aborting the whole run. Useful for rolling updates across large fleets.
- hosts: webservers
max_fail_percentage: 30
serial: 10
tasks:
- name: Update package
ansible.builtin.apt:
name: myapp
state: latest
ignore_unreachable
Separate from ignore_errors. Allows the play to continue for a host even when it cannot be contacted at all.
- name: Ping possibly-decommissioned hosts
ansible.builtin.ping:
ignore_unreachable: true
force_handlers
Normally, if a task fails, any handlers notified earlier in the run are skipped for that host. force_handlers: true (playbook or play level, or --force-handlers on the CLI) runs pending handlers anyway, which matters for cleanup actions like restarting a service after a partially-applied config change.
Loops and per-item failure
By default, a failure on one loop item stops the entire task (and thus the host) rather than skipping to the next item. To collect results across all items regardless of individual failure:
- name: Install multiple packages, continue past failures
ansible.builtin.apt:
name: "{{ item }}"
state: present
loop:
- nginx
- nonexistent-package
- curl
register: results
ignore_errors: true
- name: Report which installs failed
ansible.builtin.debug:
msg: "{{ item.item }} failed"
loop: "{{ results.results }}"
when: item.failed
Error propagation across roles and includes
block/rescue scopes to the tasks file it's declared in. A failure inside an included tasks file (via include_tasks) propagates up and can be caught by a block/rescue wrapping the include_tasks call itself. Statically imported tasks (import_tasks) are flattened at parse time, so the same rule applies but there is no separate task boundary to reason about.
- block:
- name: Run role-like task file that may fail internally
ansible.builtin.include_tasks: deploy_steps.yml
rescue:
- name: Handle failure from included tasks
ansible.builtin.debug:
msg: "deploy_steps.yml failed, cleaning up"
Clearing host errors mid-play
A host that has failed and been excluded from the rest of the play can be forcibly re-included using the meta action:
- name: Clear host errors and resume
meta: clear_host_errors
This is rarely needed outside of custom recovery logic that runs after a rescue block has handled the underlying problem.
Reference: mechanisms by scope
| Mechanism | Scope | Purpose |
|---|---|---|
ignore_errors | Task | Suppress failure, continue play on that host |
ignore_unreachable | Task | Suppress unreachable-host errors |
failed_when | Task | Redefine what counts as failure |
changed_when | Task | Redefine what counts as changed (reporting only) |
block/rescue/always | Task group | Structured try/except/finally |
fail / assert | Task | Force a failure on explicit condition |
any_errors_fatal | Play/block | Abort all hosts on any single host failure |
max_fail_percentage | Play | Tolerate a threshold of host failures |
force_handlers | Play/global | Run notified handlers even after a failure |
meta: clear_host_errors | Task | Re-include a previously failed host in the run |