Introduction

By default, Ansible stops when a task fails. Error handling lets you control that behavior — retry, ignore, rescue, or fail fast. Use block/rescue/always (like try/catch/finally), ignore_errors, failed_when, and any_errors_fatal to build resilient playbooks.

block / rescue / always

- name: Deploy with rollback
  block:
    - name: Deploy new version
      ansible.builtin.shell: /opt/deploy.sh {{ version }}

    - name: Run smoke tests
      ansible.builtin.uri:
        url: http://localhost:8080/health
        status_code: 200

  rescue:
    - name: Rollback on failure
      ansible.builtin.shell: /opt/rollback.sh
    
    - name: Notify team
      ansible.builtin.uri:
        url: "{{ slack_webhook }}"
        method: POST
        body_format: json
        body:
          text: "Deploy failed on {{ inventory_hostname }}, rolled back"

  always:
    - name: Clean up temp files
      ansible.builtin.file:
        path: /tmp/deploy-artifacts
        state: absent

    - name: Log result
      ansible.builtin.shell: echo "Deploy attempt at $(date)" >> /var/log/deploys.log
  • block — tasks to try
  • rescue — runs only if block fails (like catch)
  • always — runs regardless (like finally)

ignore_errors

# Continue even if task fails
- name: Stop old service (might not exist)
  ansible.builtin.service:
    name: old-myapp
    state: stopped
  ignore_errors: true

# Check result later
- name: Check if app exists
  ansible.builtin.command: which myapp
  register: app_check
  ignore_errors: true

- name: Install if missing
  ansible.builtin.shell: curl -sL https://install.example.com | bash
  when: app_check.rc != 0

failed_when

Custom failure conditions — override what Ansible considers a failure:

# Command returns rc=1 for "needs update" (not an error)
- name: Check for updates
  ansible.builtin.command: /opt/app/check-updates
  register: update_check
  failed_when: update_check.rc > 1    # Only fail on rc > 1

# Fail based on output content
- name: Run health check
  ansible.builtin.shell: /opt/app/healthcheck
  register: health
  failed_when: "'CRITICAL' in health.stdout"

# Multiple conditions
- name: Validate config
  ansible.builtin.command: /opt/app/validate-config
  register: validate
  failed_when:
    - validate.rc != 0
    - "'WARNING' not in validate.stderr"    # Warnings are OK

changed_when

Control when Ansible reports a task as "changed":

# Read-only command — never changed
- name: Check version
  ansible.builtin.command: /opt/app/version
  register: version
  changed_when: false

# Changed only when output indicates it
- name: Apply database migration
  ansible.builtin.command: /opt/app/migrate
  register: migrate
  changed_when: "'Applied' in migrate.stdout"

any_errors_fatal

Stop ALL hosts immediately when any single host fails:

---
- name: Critical deployment
  hosts: webservers
  any_errors_fatal: true    # If one host fails, stop everything
  tasks:
    - name: Deploy
      ansible.builtin.shell: /opt/deploy.sh

    - name: Verify
      ansible.builtin.uri:
        url: "http://{{ ansible_host }}:8080/health"

Without any_errors_fatal, Ansible removes the failed host and continues with the rest. With it, the entire play stops.

max_fail_percentage

Allow partial failure:

---
- name: Rolling update
  hosts: webservers
  serial: 5
  max_fail_percentage: 20    # Stop if >20% of batch fails
  tasks:
    - name: Deploy
      ansible.builtin.shell: /opt/deploy.sh

Retry with until

- name: Wait for service
  ansible.builtin.uri:
    url: http://localhost:8080/health
    status_code: 200
  register: result
  until: result.status == 200
  retries: 30        # Try 30 times
  delay: 10          # Wait 10 seconds between tries

Practical Patterns

Graceful Service Restart

- name: Restart with health check
  block:
    - name: Restart service
      ansible.builtin.service:
        name: myapp
        state: restarted

    - name: Wait for healthy
      ansible.builtin.uri:
        url: http://localhost:8080/health
        status_code: 200
      retries: 12
      delay: 5
      register: health
      until: health.status == 200

  rescue:
    - name: Service failed to restart
      ansible.builtin.debug:
        msg: "WARNING: {{ inventory_hostname }} failed health check after restart"

    - name: Try starting again
      ansible.builtin.service:
        name: myapp
        state: started

Pre-Flight Validation with Bail-Out

---
- name: Deploy application
  hosts: appservers
  any_errors_fatal: true
  tasks:
    - name: Pre-flight checks
      block:
        - name: Check disk space
          ansible.builtin.shell: df / --output=avail | tail -1
          register: disk
          failed_when: disk.stdout | int < 5242880

        - name: Check database connectivity
          ansible.builtin.command: pg_isready -h {{ db_host }}
          changed_when: false

        - name: Check required services
          ansible.builtin.command: systemctl is-active {{ item }}
          loop: [nginx, redis]
          changed_when: false

      rescue:
        - name: Pre-flight failed
          ansible.builtin.fail:
            msg: "Pre-flight checks failed on {{ inventory_hostname }}. Aborting all deployments."

Ignore Individual Items in a Loop

- name: Stop services (some may not exist)
  ansible.builtin.service:
    name: "{{ item }}"
    state: stopped
  loop:
    - old-app
    - legacy-worker
    - temp-service
  ignore_errors: true

# Or check first
- name: Check services
  ansible.builtin.command: systemctl status {{ item }}
  loop: [old-app, legacy-worker]
  register: service_status
  ignore_errors: true
  changed_when: false

- name: Stop only existing services
  ansible.builtin.service:
    name: "{{ item.item }}"
    state: stopped
  loop: "{{ service_status.results }}"
  when: item.rc == 0

Nested Blocks

- block:
    - name: Main deployment
      block:
        - name: Deploy code
          ansible.builtin.shell: /opt/deploy.sh

        - name: Run migrations
          ansible.builtin.shell: /opt/migrate.sh

      rescue:
        - name: Rollback code
          ansible.builtin.shell: /opt/rollback.sh

    - name: Verify deployment
      ansible.builtin.uri:
        url: http://localhost:8080/health

  rescue:
    - name: Full failure — notify
      ansible.builtin.debug:
        msg: "Complete deployment failure on {{ inventory_hostname }}"

Summary Table

TechniqueUse When
ignore_errors: trueTask failure is acceptable
failed_whenCustom failure condition
changed_whenCustom change detection
block/rescue/alwaysError recovery / rollback
any_errors_fatalStop ALL hosts on any failure
max_fail_percentageAllow partial failure in rolling updates
until/retries/delayRetry until condition met
assertPre-flight validation

Conclusion

Error handling makes playbooks production-ready. Use block/rescue/always for deploy-and-rollback patterns. Use ignore_errors when failure is acceptable. Use failed_when to customize what counts as failure. Use any_errors_fatal for critical operations where partial success is worse than stopping. Use until/retries for eventual consistency. Combine them to build resilient automation that handles real-world failure gracefully.