Ansible Error Handling: block, rescue, and always Explained (2026 Guide)

By LinuxCert.Guru Team·

Objective

This guide covers structured error handling in Ansible playbooks using block, rescue, and always. By the end, you will know how to:

  • Group related tasks using block so they can be managed as a single unit
  • Catch and handle task failures gracefully with the rescue section
  • Execute cleanup and logging tasks regardless of success or failure using always
  • Build playbooks that recover automatically from expected failures instead of stopping completely
  • Apply these patterns to real production automation scenarios

Ansible playbooks fail. That is not a bug, it is a reality of automating real infrastructure where services are sometimes down, packages are sometimes unavailable, and network conditions are sometimes unreliable. The question is whether your playbook fails gracefully with a sensible recovery action or fails badly and leaves the system in an unknown state. block, rescue, and always are how you make the answer consistently the former.

The Problem: Default Ansible Failure Behaviour

By default, when a task fails in an Ansible playbook, execution stops for that host. The remaining tasks in the play are skipped. If the failed task was part of a multi-step operation such as installing a service, starting it, and verifying it, you may end up with a partially configured system and no automatic recovery.

For simple one-off tasks that is acceptable. For production automation that runs on critical infrastructure, it is not. You need a way to define what should happen when something goes wrong, not just what should happen when everything goes right.

The Solution: block, rescue, and always

Think of this structure the same way you think of try/catch/finally in a programming language. The mapping is direct:

Ansible Section Programming Equivalent When It Runs Typical Use
block try Always, unless a previous task already failed The main tasks you want to execute
rescue catch Only when a task inside the block fails Fallback actions, error recovery, notifications
always finally Always, regardless of whether block succeeded or failed Cleanup, logging, releasing locks, sending status reports

Basic Structure

Here is the skeleton of a playbook using all three sections:

---
- name: Demonstrate block, rescue, and always
  hosts: all
  tasks:

    - name: Error handling example
      block:
        - name: Task 1 - attempt the main work
          ansible.builtin.command: /usr/local/bin/deploy.sh

        - name: Task 2 - additional step if task 1 succeeds
          ansible.builtin.service:
            name: myapp
            state: started

      rescue:
        - name: Recovery task - runs only if block failed
          ansible.builtin.debug:
            msg: "Deployment failed. Running rollback."

        - name: Rollback the deployment
          ansible.builtin.command: /usr/local/bin/rollback.sh

      always:
        - name: Cleanup task - runs regardless of outcome
          ansible.builtin.debug:
            msg: "Deployment attempt finished. Logging result."

The flow in plain language:

  • Ansible runs the tasks in block in order
  • If any task in block fails, Ansible skips the remaining block tasks and runs rescue instead
  • After either block or rescue completes, always runs no matter what happened

Practical Examples

Example 1: Service Deployment with Rollback

This is the most common real-world pattern. Install a package, configure it, start the service. If anything fails, roll back and send a notification. Always log the outcome.

---
- name: Deploy web application
  hosts: webservers
  tasks:

    - name: Web application deployment
      block:
        - name: Install the application package
          ansible.builtin.dnf:
            name: myapp
            state: present

        - name: Deploy configuration file
          ansible.builtin.template:
            src: myapp.conf.j2
            dest: /etc/myapp/myapp.conf
            owner: root
            group: root
            mode: '0644'

        - name: Start and enable the application service
          ansible.builtin.service:
            name: myapp
            state: started
            enabled: true

        - name: Verify the application is responding
          ansible.builtin.uri:
            url: http://localhost:8080/health
            status_code: 200

      rescue:
        - name: Log the failure
          ansible.builtin.debug:
            msg: "Deployment failed on {{ inventory_hostname }}. Starting rollback."

        - name: Remove the failed package
          ansible.builtin.dnf:
            name: myapp
            state: absent

        - name: Notify the team of the failure
          ansible.builtin.mail:
            to: ops-team@example.com
            subject: "Deployment failed on {{ inventory_hostname }}"
            body: "The myapp deployment failed. Rollback has been initiated."

      always:
        - name: Record deployment attempt in the log
          ansible.builtin.lineinfile:
            path: /var/log/deployments.log
            line: "{{ ansible_date_time.iso8601 }} - Deployment attempted on {{ inventory_hostname }}"
            create: true

Example 2: Database Backup with Guaranteed Cleanup

Run a backup operation. If it fails, flag the failure and clean up any partial files. Always release the lock file regardless of what happened, because a stuck lock file that prevents future backups is a common production problem.

---
- name: Database backup with error handling
  hosts: dbservers
  vars:
    backup_dir: /var/backups/db
    lock_file: /var/run/db_backup.lock

  tasks:

    - name: Database backup process
      block:
        - name: Create backup lock file
          ansible.builtin.file:
            path: "{{ lock_file }}"
            state: touch

        - name: Run database dump
          ansible.builtin.command:
            cmd: "mysqldump --all-databases > {{ backup_dir }}/backup_{{ ansible_date_time.date }}.sql"

        - name: Compress the backup file
          ansible.builtin.archive:
            path: "{{ backup_dir }}/backup_{{ ansible_date_time.date }}.sql"
            dest: "{{ backup_dir }}/backup_{{ ansible_date_time.date }}.sql.gz"
            remove: true

      rescue:
        - name: Log the backup failure
          ansible.builtin.debug:
            msg: "Backup failed on {{ inventory_hostname }}. Cleaning up partial files."

        - name: Remove any partial backup files
          ansible.builtin.find:
            paths: "{{ backup_dir }}"
            patterns: "backup_{{ ansible_date_time.date }}*"
          register: partial_files

        - name: Delete partial backup files
          ansible.builtin.file:
            path: "{{ item.path }}"
            state: absent
          loop: "{{ partial_files.files }}"

      always:
        - name: Remove the lock file regardless of outcome
          ansible.builtin.file:
            path: "{{ lock_file }}"
            state: absent

Example 3: Checking ansible_failed_task and ansible_failed_result

Inside a rescue block, Ansible provides special variables that tell you exactly what failed and why. This is useful for building informative notifications and logs.

---
- name: Error handling with failure details
  hosts: all
  tasks:

    - name: Task group with detailed error capture
      block:
        - name: Attempt a potentially failing task
          ansible.builtin.command: /usr/local/bin/validate.sh
          register: validation_result

      rescue:
        - name: Display what failed and why
          ansible.builtin.debug:
            msg:
              - "Failed task: {{ ansible_failed_task.name }}"
              - "Return code: {{ ansible_failed_result.rc | default('N/A') }}"
              - "Error output: {{ ansible_failed_result.stderr | default('None') }}"

        - name: Send detailed failure notification
          ansible.builtin.uri:
            url: https://alerts.example.com/webhook
            method: POST
            body_format: json
            body:
              host: "{{ inventory_hostname }}"
              task: "{{ ansible_failed_task.name }}"
              error: "{{ ansible_failed_result.stderr | default('Unknown error') }}"

      always:
        - name: Final status log
          ansible.builtin.debug:
            msg: "Block execution complete on {{ inventory_hostname }}"

Behaviour Details Worth Understanding

Scenario What Runs Final Host Status
All block tasks succeed block, then always Success
A block task fails, rescue succeeds block (partial), then rescue, then always Success (rescue recovered it)
A block task fails, rescue also fails block (partial), then rescue (partial), then always Failed
All block tasks succeed, always fails block, then always Failed (always failure counts)
Block fails with no rescue defined block (partial), no rescue, always (if defined) Failed

One important point from the second row: if the rescue section completes successfully, Ansible considers the overall task group to have succeeded, even though the block failed. This is by design: rescue is a legitimate recovery path, not just error logging.

Common Patterns and When to Use Each

Pattern Use Case Example
block + rescue only Fallback action needed on failure, no cleanup required Try primary DNS server, fall back to secondary if unavailable
block + always only Cleanup always needed regardless of outcome, no specific recovery action Run tests, always collect test output for the report
block + rescue + always Full error handling: recovery on failure, cleanup always Deploy service, rollback on failure, always release the lock
Nested blocks Different error handling for different parts of a complex task Outer block handles infrastructure errors; inner block handles application errors separately
Multiple blocks in one play Independent error handling for unrelated task groups Separate blocks for database setup and web server setup, each with its own rescue

Combining with ignore_errors and failed_when

Block and rescue are not the only error handling tools in Ansible. They work best alongside two other directives:

# ignore_errors: continue even if this specific task fails
# Useful when a task failing is expected and acceptable
- name: Stop the service (may already be stopped)
  ansible.builtin.service:
    name: myapp
    state: stopped
  ignore_errors: true

# failed_when: define your own failure condition
# Useful when a command returns 0 but the output signals a problem
- name: Check application health
  ansible.builtin.command: /usr/local/bin/healthcheck.sh
  register: health_result
  failed_when: "'ERROR' in health_result.stdout"

# Combining with block: use ignore_errors inside block for non-critical steps
# but let block/rescue handle critical failures
- name: Deployment with mixed error handling
  block:
    - name: Optional pre-flight check
      ansible.builtin.command: /usr/local/bin/preflight.sh
      ignore_errors: true

    - name: Critical deployment step
      ansible.builtin.command: /usr/local/bin/deploy.sh

  rescue:
    - name: Handle critical deployment failure
      ansible.builtin.debug:
        msg: "Critical step failed, running recovery"

Common Mistakes

  • Putting rescue tasks that can also fail without their own error handling. If a task in rescue fails, the entire block fails with no further recovery. For critical rescue operations, consider nesting a block inside rescue.
    • Keep rescue tasks simple and reliable. A rollback script that can also fail is not a safe rescue action.
  • Forgetting that rescue changes the final status. A successful rescue means the play host is marked as succeeded. If you need to mark the play as failed even after a successful rescue, use ansible.builtin.fail at the end of your rescue block.
  • Treating always as a post-task handler. always runs for that specific block, not at the end of the entire play. For play-level cleanup, use Ansible handlers or a separate task section after the block.
  • Not logging enough detail in rescue. When an automated recovery runs, you need to know it happened. Always include a debug or logging task at the start of rescue so the failure is visible in the playbook output.
    • Use ansible_failed_task.name and ansible_failed_result to capture exactly what went wrong
  • Making blocks too large. A block with 15 tasks where any one failure triggers rescue makes it difficult to diagnose which task actually failed. Keep blocks focused on a single logical operation.

Quick Reference

# Full block/rescue/always structure
block:
  - name: Main tasks here
    ...

rescue:
  - name: Recovery tasks here (only on block failure)
    ...
  # Special variables available in rescue:
  # ansible_failed_task.name - name of the task that failed
  # ansible_failed_result    - full result object from the failed task
  # ansible_failed_result.rc - return code
  # ansible_failed_result.stderr - error output

always:
  - name: Cleanup tasks here (always runs)
    ...

Error Handling and the RHCE Exam

Structured error handling with block, rescue, and always is an explicit RHCE exam objective. The exam tests your ability to write playbooks that handle failures gracefully rather than just stopping, which means you need to be able to:

  • Write a block section grouping related tasks
  • Add a rescue section that executes meaningful recovery actions when the block fails
  • Add an always section for tasks that must run regardless of outcome
  • Use the special ansible_failed_task and ansible_failed_result variables in rescue sections
  • Understand how the final host status is determined when rescue succeeds vs fails

The pattern appears in real exam tasks framed as: "configure the playbook so that if the deployment fails, the system is rolled back to its previous state, and the result is logged regardless of outcome." That description maps directly to block/rescue/always.

Practice This in a Real Environment

The best way to understand error handling is to break things on purpose. Write a block task that you know will fail, watch rescue trigger, confirm always runs, then check what ansible_failed_result contains. None of that intuition comes from reading the playbook structure on a page.

LinuxCert.Guru has a dedicated hands-on lab for this: Error Handling in Ansible. It covers block, rescue, and always through real exercises that require you to implement fault-tolerant playbooks, with auto-graded tasks that verify your error handling actually works as intended.

Practice the Ansible Error Handling lab at LinuxCert.Guru → https://linuxcert.guru/rhce/?name=rhce-error-handling

Conclusion

Ansible's block, rescue, and always sections give you the same structured error handling that good programming practice demands, applied directly to infrastructure automation. The pattern is clean, the behaviour is predictable, and the alternative, playbooks that stop completely on any unexpected failure, is not acceptable for production environments.

  • block groups the tasks you want to run, rescue defines what happens when they don't, always handles what must happen regardless
  • A successful rescue marks the host as succeeded, even if the block failed. Use ansible.builtin.fail at the end of rescue if you need to preserve the failure status.
  • ansible_failed_task and ansible_failed_result give you exact details about what went wrong inside rescue
  • Keep rescue tasks simple and reliable. A rescue that can also fail without its own recovery is a problem waiting to happen.
  • Always run blocks on a real system and test the failure path deliberately. Knowing the theory is not the same as having watched rescue trigger and confirmed always ran.

Start the Ansible Error Handling lab at LinuxCert.Guru → https://linuxcert.guru/rhce/?name=rhce-error-handling

$_
Ready to go beyond reading?Practice with free, auto-graded RHCSA labs.
Start free →