Introduction

Ansible's block/rescue/always is the equivalent of try/catch/finally in programming languages. block groups tasks that might fail, rescue handles failures, and always runs cleanup regardless of success or failure. This pattern replaces fragile ignore_errors with structured error handling.

Basic Structure

- name: Deploy with error handling
  block:
    # TRY — tasks that might fail
    - name: Deploy new version
      ansible.builtin.unarchive:
        src: "app-{{ version }}.tar.gz"
        dest: /opt/app/

    - name: Run database migration
      ansible.builtin.command: /opt/app/migrate.sh

    - name: Restart application
      ansible.builtin.service:
        name: myapp
        state: restarted

  rescue:
    # CATCH — runs only if block fails
    - name: Rollback to previous version
      ansible.builtin.command: /opt/app/rollback.sh

    - name: Notify team of failure
      ansible.builtin.uri:
        url: "{{ slack_webhook }}"
        body: '{"text": "❌ Deploy failed on {{ inventory_hostname }}, rolled back"}'

  always:
    # FINALLY — runs regardless of success or failure
    - name: Remove temporary files
      ansible.builtin.file:
        path: /tmp/deploy-staging/
        state: absent

    - name: Re-enable monitoring
      ansible.builtin.uri:
        url: "{{ monitoring_api }}/enable/{{ inventory_hostname }}"

block — Group Tasks

# Apply shared attributes to multiple tasks
- name: Install and configure app
  block:
    - ansible.builtin.apt:
        name: "{{ item }}"
        state: present
      loop: [nginx, python3, redis]

    - ansible.builtin.template:
        src: app.conf.j2
        dest: /etc/app/config.yml

    - ansible.builtin.service:
        name: myapp
        state: started
  become: true
  when: ansible_os_family == "Debian"
  # become and when apply to ALL tasks in the block

rescue — Handle Failures

- block:
    - name: Pull Docker image
      community.docker.docker_image:
        name: "myapp:{{ new_version }}"
        source: pull

    - name: Start new container
      community.docker.docker_container:
        name: myapp
        image: "myapp:{{ new_version }}"
        state: started

  rescue:
    - name: Log the error
      ansible.builtin.debug:
        msg: "Deploy failed: {{ ansible_failed_result }}"

    - name: Start previous version
      community.docker.docker_container:
        name: myapp
        image: "myapp:{{ current_version }}"
        state: started

    # After rescue completes successfully, play continues
    # The host is NOT marked as failed

rescue Variables

rescue:
  # These special variables are available in rescue
  - ansible.builtin.debug:
      msg:
        - "Failed task: {{ ansible_failed_task.name }}"
        - "Failed result: {{ ansible_failed_result }}"

always — Guaranteed Cleanup

# Maintenance window pattern
- block:
    - name: Disable alerts
      ansible.builtin.uri:
        url: "{{ pagerduty_api }}/maintenance"
        method: POST

    - name: Perform upgrade
      ansible.builtin.include_role:
        name: upgrade

  always:
    # Re-enable alerts whether upgrade succeeded or failed
    - name: Enable alerts
      ansible.builtin.uri:
        url: "{{ pagerduty_api }}/maintenance"
        method: DELETE

# Lock file pattern
- block:
    - name: Acquire lock
      ansible.builtin.copy:
        content: "{{ ansible_date_time.iso8601 }}"
        dest: /tmp/deploy.lock

    - name: Run deployment
      ansible.builtin.include_tasks: deploy.yml

  always:
    - name: Release lock
      ansible.builtin.file:
        path: /tmp/deploy.lock
        state: absent

Real-World Patterns

Rolling Deploy with Rollback

- hosts: webservers
  serial: 1
  tasks:
    - block:
        - name: Remove from LB
          community.general.haproxy:
            state: disabled
            host: "{{ inventory_hostname }}"
          delegate_to: "{{ lb_host }}"

        - name: Deploy new version
          ansible.builtin.include_role:
            name: deploy

        - name: Health check
          ansible.builtin.uri:
            url: "http://localhost:{{ app_port }}/health"
          register: health
          until: health.status == 200
          retries: 10
          delay: 5

      rescue:
        - name: Rollback
          ansible.builtin.include_role:
            name: rollback

        - name: Fail the host
          ansible.builtin.fail:
            msg: "Deploy failed on {{ inventory_hostname }}, rolled back"

      always:
        - name: Re-add to LB
          community.general.haproxy:
            state: enabled
            host: "{{ inventory_hostname }}"
          delegate_to: "{{ lb_host }}"

Database Migration with Savepoint

- block:
    - name: Create database backup
      ansible.builtin.command: >
        pg_dump {{ db_name }} -f /backup/pre-migration.sql

    - name: Run migration
      ansible.builtin.command: /opt/app/migrate.sh
      register: migration

    - name: Verify migration
      ansible.builtin.command: /opt/app/verify-schema.sh

  rescue:
    - name: Restore database
      ansible.builtin.command: >
        psql {{ db_name }} < /backup/pre-migration.sql

    - name: Report failure
      ansible.builtin.fail:
        msg: "Migration failed: {{ ansible_failed_result.msg | default('unknown') }}"

  always:
    - name: Clean up backup (keep last 5)
      ansible.builtin.shell: >
        ls -t /backup/pre-migration*.sql | tail -n +6 | xargs rm -f

block vs ignore_errors

# ❌ ignore_errors — hides ALL errors, no recovery logic
- name: Risky operation
  ansible.builtin.command: /opt/risky.sh
  ignore_errors: true
  # What went wrong? No idea. No recovery. No cleanup.

# ✅ block/rescue — structured error handling
- block:
    - name: Risky operation
      ansible.builtin.command: /opt/risky.sh
  rescue:
    - name: Handle the error specifically
      ansible.builtin.debug:
        msg: "Failed: {{ ansible_failed_result.stderr }}"
    - name: Run recovery
      ansible.builtin.command: /opt/recover.sh

Common Mistakes

# ❌ Rescue swallows the error silently
rescue:
  - debug:
      msg: "Something failed"
  # Play continues as if nothing happened!

# ✅ Re-raise if you want the play to fail after cleanup
rescue:
  - name: Clean up
    ansible.builtin.command: /opt/cleanup.sh
  - name: Fail with context
    ansible.builtin.fail:
      msg: "Deploy failed after cleanup: {{ ansible_failed_task.name }}"

# ❌ Nesting blocks too deeply
block:
  - block:
      - block:  # 3 levels deep — unreadable!

# ✅ Extract to include_tasks or roles
block:
  - ansible.builtin.include_role:
      name: deploy
rescue:
  - ansible.builtin.include_role:
      name: rollback

Conclusion

block groups related tasks and applies shared attributes. rescue catches failures and runs recovery logic (rollback, notification). always guarantees cleanup runs regardless of outcome (release locks, re-enable monitoring, restore LB). This pattern replaces ignore_errors with intentional, structured error handling. The most common real-world use: deploy in block, rollback in rescue, re-enable load balancer in always.