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.

- 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

MechanismScopePurpose
ignore_errorsTaskSuppress failure, continue play on that host
ignore_unreachableTaskSuppress unreachable-host errors
failed_whenTaskRedefine what counts as failure
changed_whenTaskRedefine what counts as changed (reporting only)
block/rescue/alwaysTask groupStructured try/except/finally
fail / assertTaskForce a failure on explicit condition
any_errors_fatalPlay/blockAbort all hosts on any single host failure
max_fail_percentagePlayTolerate a threshold of host failures
force_handlersPlay/globalRun notified handlers even after a failure
meta: clear_host_errorsTaskRe-include a previously failed host in the run