Skip to content

Error Handling in Blocks


This is block:’s real payoff — not just grouping tasks, but genuine try/catch/finally error handling. rescue: runs only if something in block: failed. always: runs no matter what happened.

block/rescue/always: Try, Catch, Finally

block_rescue_always.yaml
- name: Try Rescue Always Demo
  block:
    - name: This will fail
      ansible.builtin.command: /bin/false
  rescue:
    - name: Handle the failure
      ansible.builtin.debug:
        msg: "Something went wrong, but we're handling it"
  always:
    - name: This always runs
      ansible.builtin.debug:
        msg: "Cleanup or final step, regardless of outcome"
fatal: [ubuntu]: FAILED! => {"changed": true, "cmd": ["/bin/false"], ...}
ok: [ubuntu] => { "msg": "Something went wrong, but we're handling it" }
ok: [ubuntu] => { "msg": "Cleanup or final step, regardless of outcome" }

What Happens On Success (No Failure)

Swap /bin/false for /bin/true, and the picture changes:

changed: [ubuntu]
ok: [ubuntu] => { "msg": "Cleanup or final step, regardless of outcome" }

rescue: never even runs — it’s not evaluated at all when nothing failed. always: runs either way, which is exactly the “finally” behavior its name suggests.

What Happens On Failure

Two details worth being explicit about. First: if a task inside block: fails, any remaining tasks in that same block are skipped — execution doesn’t continue further into the block once something fails, it jumps straight to rescue:. Second, and more important: once rescue: itself completes successfully, the overall block is considered recovered, not failed — the play continues normally afterward, rather than halting the way an unhandled task failure would.

This is the entire point of the pattern: converting a failure that would otherwise stop everything into one that’s handled, with the playbook free to keep going. See it yourself:

Without Rescue

without_rescue.yaml
- name: Block Play
  hosts: ubuntu
  tasks:
    - name: Without Rescue
      block:
        - name: This Will Fail
          ansible.builtin.command: /bin/false

    - name: This Never Runs
      ansible.builtin.debug:
        msg: "I can't run"
TASK [This Will Fail] *****************************************************************
[ERROR]: Task failed: Module failed: The command exited with a non-zero return code.
...
fatal: [ubuntu]: FAILED! => {"changed": true, "cmd": ["/bin/false"], ...}

With Rescue

withrescue.yaml
- name: Block Play
  hosts: ubuntu
  tasks:
    - name: Without Rescue
      block:
        - name: This Will Fail
          ansible.builtin.command: /bin/false

      rescue:
        - name: Rescuing Failed task
          ansible.builtin.debug:
            msg: "Rescue Done!"

    - name: This Runs Now
      ansible.builtin.debug:
        msg: "I can run because `rescue` recovered"
TASK [This Will Fail] *****************************************************************
[ERROR]: Task failed: Module failed: The command exited with a non-zero return code.
...
fatal: [ubuntu]: FAILED! => {"changed": true, "cmd": ["/bin/false"], ...}
TASK [Rescuing Failed task] ***********************************************************
ok: [ubuntu] => {
    "msg": "Rescue Done!"
}
TASK [This Runs Now] ******************************************************************
ok: [ubuntu] => {
    "msg": "I can run because `rescue` recovered"
}

Accessing What Failed: ansible_failed_task And ansible_failed_result

Inside rescue:, two special variables are automatically available, describing exactly what went wrong — without needing to manually register every single task in the block just in case:

rescue:
  - name: Report exactly what failed
    ansible.builtin.debug:
      msg: "Task '{{ ansible_failed_task.name }}' failed with: {{ ansible_failed_result.msg | default(ansible_failed_result) }}"

ansible_failed_task gives you the failed task’s own definition (its name, among other things); ansible_failed_result gives you its full result structure, the same shape a normal register would have produced.

A Realistic Example: A Resilient Operation

resilient_deploy.yaml
- name: Resilient Deployment
  block:
    - name: Attempt primary deployment method
      ansible.builtin.command: /bin/false
      register: primary_attempt

    - name: Confirm primary succeeded
      ansible.builtin.debug:
        msg: "Primary deployment succeeded"

  rescue:
    - name: Report the failure clearly
      ansible.builtin.debug:
        msg: "Primary method failed on task '{{ ansible_failed_task.name }}' — falling back"

    - name: Attempt fallback method
      ansible.builtin.command: echo "fallback deployment"
      register: fallback_attempt

  always:
    - name: Log that the deployment attempt finished
      ansible.builtin.debug:
        msg: "Deployment attempt finished (success or fallback)"

Primary fails, the fallback runs in its place, and the closing log line fires regardless of which path was actually taken — a genuinely resilient operation, built from three plain keywords.

A Gotcha: rescue Only Catches Failures From Its Own Block

Nest a block inside a block, and this gets genuinely subtle. If the inner block has its own rescue: and that successfully handles the failure, the outer block never even sees a problem at all. But if the inner rescue: itself fails, the outer will then see problem and handles it.

nested_handled.yaml
- name: Outer Block
  block:
    - name: Inner Block With Its Own Rescue
      block:
        - name: This fails
          ansible.builtin.command: /bin/false
      rescue:
        - name: Inner rescue handles it
          ansible.builtin.debug:
            msg: "Handled at the inner level"
  rescue:
    - name: Outer rescue (does NOT run)
      ansible.builtin.debug:
        msg: "This never prints"
ok: [ubuntu] => { "msg": "Handled at the inner level" }

Only the inner message prints — as far as the outer block is concerned, nothing ever failed, since the inner rescue: fully absorbed it. Remove the inner block’s own rescue: entirely, and the picture flips:

nested_propagates.yaml
- name: Outer Block
  block:
    - name: Inner Block Without Rescue
      block:
        - name: This fails
          ansible.builtin.command: /bin/false
  rescue:
    - name: Outer rescue (THIS runs)
      ansible.builtin.debug:
        msg: "Caught by the outer rescue, since inner had none"
ok: [ubuntu] => { "msg": "Caught by the outer rescue, since inner had none" }

With no inner rescue: to catch it, the failure propagates up to the nearest one that exists — the outer block’s. Whether a given failure gets handled at the level you expect depends entirely on which block actually has a rescue: waiting for it.

Best Practices

  • Use block/rescue/always for any operation where a failure shouldn’t halt the whole play — fallback logic, cleanup that must happen regardless of outcome, or simply a clearer failure report than Ansible’s default.
  • Use ansible_failed_task/ansible_failed_result inside rescue: rather than manually registering and checking every task in the block just to find out what went wrong.
  • Be deliberate about which level of nested blocks should actually handle a given failure — an inner rescue: absorbs it there and then; its absence lets the failure travel up to wherever a rescue: actually exists.
Last updated on