Error handling, blocks and rescue

block, rescue and always, ignore_errors and failed_when, asserting prerequisites, retries and timeouts, and making a failed run safe to repeat.

block, rescue and always

- name: Deploy with a rollback path
  block:
    - name: Stop the service
      ansible.builtin.systemd:
        name: app
        state: stopped

    - name: Copy the new artefact
      ansible.builtin.copy:
        src: "app-{{ app_version }}.jar"
        dest: /srv/app/app.jar
        owner: app
        mode: "0640"

    - name: Start the service
      ansible.builtin.systemd:
        name: app
        state: started

    - name: Wait until the health endpoint answers
      ansible.builtin.uri:
        url: http://127.0.0.1:8080/health
        status_code: 200
      register: health
      until: health.status == 200
      retries: 12
      delay: 5

  rescue:
    - name: Restore the previous artefact
      ansible.builtin.copy:
        src: /srv/app/app.jar.previous
        dest: /srv/app/app.jar
        remote_src: true
        mode: "0640"

    - name: Start the known-good version
      ansible.builtin.systemd:
        name: app
        state: restarted

    - name: Fail the run so a human looks at it
      ansible.builtin.fail:
        msg: "Deployment of {{ app_version }} failed; rolled back to the previous build."

  always:
    - name: Always leave a record of the attempt
      ansible.builtin.copy:
        content: |
          version={{ app_version }}
          at={{ ansible_date_time.iso8601 }}
          host={{ inventory_hostname }}
        dest: /var/log/app/last-deploy.txt
        mode: "0644"
  • A rescue runs only for the host whose block failed, and only for tasks inside that block. It is per-host error handling, not global.
  • always runs regardless of outcome, which is where cleanup and reporting belong.
  • Re-raising with fail in the rescue is deliberate: a run that silently recovered hides an incident from the pipeline.
  • Every task in a block must be safe to run again, because a rescue does not undo a partial change on a host that failed midway.

ignore_errors, failed_when and assertions

- name: Check a prerequisite without failing the play
  ansible.builtin.command: /usr/local/bin/app --version
  register: app_version
  failed_when: false
  changed_when: false

- name: Assert the prerequisites in one place
  ansible.builtin.assert:
    that:
      - ansible_memtotal_mb >= 4096
      - app_version.stdout is version('2.0.0', '>=')
      - ansible_distribution in ['Ubuntu', 'Debian']
    fail_msg: >-
      {{ inventory_hostname }} does not meet the requirements:
      memory {{ ansible_memtotal_mb }}MB, version {{ app_version.stdout | default('missing') }}
    success_msg: "prerequisites satisfied"
    quiet: true

- name: Try a task that may legitimately fail
  ansible.builtin.command: /usr/local/bin/app --migrate
  register: migrate
  # 4 means "already applied", which is a success for this task
  failed_when: migrate.rc not in [0, 4]
  changed_when: migrate.rc == 0
TechniqueEffectPrefer instead
ignore_errors: trueContinue as if nothing happenedRegister and check failed_when
failed_when: falseNever fail the taskA read-only probe
assertFail with a clear messageSilent failure deep in a task
meta: end_hostStop this host, keep the play goingFailing the whole play
max_fail_percentageAbort when too many hosts failRolling out to a broken fleet

ignore_errors is almost always the wrong tool because it discards the failure instead of expressing what counts as success. A registered result with an explicit failed_when says the same thing and keeps the failure visible in the report.

Making a failed run safe to repeat

- name: A migration that is safe to run twice
  block:
    - name: Check whether the migration already ran
      ansible.builtin.stat:
        path: /var/lib/app/.migrated-{{ migration_version }}
      register: marker

    - name: Run the migration once
      ansible.builtin.command: /usr/local/bin/app migrate --to {{ migration_version }}
      when: not marker.stat.exists
      notify: Restart app

    - name: Leave the marker only after success
      ansible.builtin.file:
        path: /var/lib/app/.migrated-{{ migration_version }}
        state: touch
        mode: "0644"
      when: not marker.stat.exists
  rescue:
    - name: Report a partial migration without pretending it completed
      ansible.builtin.fail:
        msg: "Migration {{ migration_version }} failed on {{ inventory_hostname }}; the marker was not written."
⚠️
A marker written before the work completes is worse than no marker: the next run skips a migration that never finished. Write the marker only after the operation succeeds, and make the operation itself idempotent where you can.

FAQ

Does rescue undo the tasks that already ran?
No. It only runs your recovery tasks. Any change made before the failure is still in place on that host, so recovery tasks must account for a partially applied state.
Where should I put prerequisite checks?
In a dedicated play or a role called first, with a single assert block. Failing at the start, with a message that names the host and the missing requirement, is far cheaper than failing halfway through a deploy.

Conditionals, loops and handlers in depth Testing automation: lint, check mode and Molecule

Last refreshed 2026-09-18.