Ansible Blocks, Rescue, Always, failed_when and changed_when Explained

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.

yaml
- 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 a block when several tasks share a condition or belong to one logical step.

yaml
- 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"
bash
ansible-playbook block-group.yml

Sample output:

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=0

The 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

IMPORTANT
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:

yaml
---
- 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"
bash
ansible-playbook block-rescue-always.yml

Sample output:

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=0

rescued=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:

yaml
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:

yaml
failed_when: result.rc != 0 or 'ERROR' in result.stderr

Custom 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:

yaml
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.

yaml
- 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: false
bash
ansible-playbook failed-when-rc.yml

Sample output:

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=0

rc 1 (not found) is treated as success; only rc greater than 1 would fail the task.

Fail when specific text appears in stderr:

yaml
- 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: false
bash
ansible-playbook failed-when-stderr.yml

Sample output:

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=0

failed_when_result: true in the output confirms your expression triggered the failure.

Mark a probe as unchanged:

yaml
- name: Probe without marking changed
  ansible.builtin.command: echo "status check"
  register: probe
  changed_when: false
bash
ansible-playbook changed-when.yml

Sample output:

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:

yaml
- name: Run validation command
  ansible.builtin.command: nginx -t
  register: nginx_test
  changed_when: false
  failed_when: nginx_test.rc != 0
yaml
- 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 != 2

Registered 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:

  1. block — apply config, run migration, enable service
  2. rescue — restore backup config, disable partial state, alert operator
  3. 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.

yaml
- 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') }}"
bash
ansible-playbook failed-task-vars.yml

Sample output:

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=0

Use 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 /tmp staging 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:

ini
[lab]
localhost ansible_connection=local
unreachable1 ansible_host=192.0.2.1 ansible_connection=ssh ansible_ssh_common_args='-o ConnectTimeout=3'
yaml
---
- 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:
bash
ansible-playbook -i inventory-unreach clear-host-errors.yml

Sample output:

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=0

localhost 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
yaml
- name: Non-critical failure
  ansible.builtin.command: /bin/false
  ignore_errors: true
bash
ansible-playbook ignore-errors.yml

Sample output:

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=1

Official 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:

yaml
---
- 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"
bash
ansible-playbook flush-handlers-rescue.yml

Sample output:

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=0

Use 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

yaml
always:
  - name: Remove staging dir
    ansible.builtin.file:
      path: /tmp/deploy-staging
      state: absent

Fail only when specific text appears in stderr

See failed-when-stderr.ymlfailed_when: "'permission denied' in cmd_out.stderr".

Mark a command task unchanged

See changed-when.ymlchanged_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:

yaml
- 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 nginx

Ansible 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/always for deploy units—not single tasks wrapped “just in case.”
  • Put shared when or become on the block, not repeated per task.
  • Register command output; encode success in failed_when, not silent ignore.
  • Validate configs (validate: on template/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.

For maintainable Ansible playbooks:

  1. Group deploy steps in block; rollback in rescue; log/cleanup in always.
  2. Use failed_when on command/shell with register—do not default to ignore_errors.
  3. Use changed_when: false on probes; let copy/template report real change.
  4. Log failures with ansible_failed_task / ansible_failed_result in rescue when rollback depends on which step broke.
  5. Notify handlers only from tasks that truly changed configuration; flush in rescue only when safe.
  6. Use FQCN modules (ansible.builtin.command) in new playbooks.
  7. Know that rescue does 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.


Frequently Asked Questions

1. When does rescue run in Ansible?

rescue runs when a task inside block returns failed on that host. It does not run for unreachable hosts, undefined variables, or playbook syntax errors.

2. What is the difference between ignore_errors and block/rescue?

ignore_errors continues the play after a failed task without structured recovery. block/rescue lets you run dedicated recovery tasks in rescue and cleanup in always.

3. Does failed_when use Jinja2 braces?

No. failed_when is already a Jinja2 expression. Write probe.rc != 0, not {{ probe.rc != 0 }}.

4. Does changed_when affect handlers?

Yes. Handlers are notified only when the task final status is changed. Setting changed_when: false prevents notify from firing.

5. What does meta: clear_host_errors do?

It clears the unreachable marker on hosts in the current play so later tasks can try to connect again. It does not fix network or SSH problems by itself.

6. How do I see which task failed inside rescue?

Use ansible_failed_task for the task object and ansible_failed_result for the captured failure details, similar to register output.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)