AnsibleLII · Anti-PatternsAnti-patterns of execution
Anti-pattern: ignore_errors as a green-build button
What you'll learn
- Read a recap and identify a run that ignored failures
- Explain how ignore_unreachable makes a fleet-wide outage report as success
- Replace a blanket ignore with failed_when, rescue, or letting the task fail
- Audit a repository for the pattern and rank the findings
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
ignore_errors: true has a legitimate use and almost nobody reaches for
it for that reason. It gets added because a task failed, the run stopped,
somebody was in a hurry, and the keyword made the problem go away.
Then it gets added again. And the pipeline goes green, and stays green, and the estate underneath it stops being observable.
What it looks like in a repository
- name: Stop the application
ansible.builtin.systemd_service:
name: app
state: stopped
ignore_errors: true
- name: Deploy the new artefact
ansible.builtin.unarchive:
src: 'https://artifacts.example.com/app-{{ app_version }}.tar.gz'
dest: /srv/app
remote_src: true
ignore_errors: true
- name: Run the database migration
ansible.builtin.command:
cmd: /srv/app/bin/migrate
ignore_errors: true
- name: Start the application
ansible.builtin.systemd_service:
name: app
state: started
ignore_errors: trueRead the sequence. The artefact fails to download; the migration runs against the old code; the start succeeds because the old version is still installed. The run is green, the deployment did not happen, and the database has been migrated for a version that is not deployed.
Each ignore_errors was added for a defensible local reason. The first
one, most likely, because stopping an already-stopped service failed
once.
The recap, and the exit code
$ ansible-playbook -i inventory/hosts.yml deploy.yml; echo exit=$?TASK [Deploy the application] **************************************************
fatal: [localhost]: FAILED! => {"changed": false, "msg": "artefact checksum mismatch"}
...ignoring
TASK [Report success] **********************************************************
ok: [localhost] => {
"msg": "deployment complete"
}
PLAY RECAP *********************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=1
exit=0Two details worth having in mind at 03:00.
failed=0 and ignored=1. The recap does record it, in the last
column, which almost nobody reads. Anything summarising runs by
failed, or by exit code, sees a clean run.
Exit code 0. This is what CI reads, what a wrapper script reads, and what the monitoring check that watches for failed runs reads. The deployment failed and every automated observer was told it succeeded.
The cousin: ignore_unreachable
This one is worse, and its output is the most alarming in this part.
- name: Restart the fleet
hosts: web01.example.com,web02.example.com
gather_facts: false
ignore_unreachable: true
tasks:
- name: Restart the application
ansible.builtin.ping:$ ansible-playbook -i inventory/hosts.yml unreach.yml -T 3; echo exit=$?TASK [Restart the application] *************************************************
fatal: [web02.example.com]: UNREACHABLE! => {"changed": false, "msg": "Failed to connect to the host via ssh: ssh: connect to host 192.0.2.10 port 22: Connection timed out", "unreachable": true}
...ignoring
fatal: [web01.example.com]: UNREACHABLE! => {"changed": false, "msg": "Failed to connect to the host via ssh: ssh: connect to host 192.0.2.10 port 22: Connection timed out", "unreachable": true}
...ignoring
PLAY RECAP *********************************************************************
web01.example.com : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=1
web02.example.com : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=1
exit=0The three honest replacements
1. failed_when — be precise about what failure means
Most ignore_errors are really “this exit code is not a failure for my
purposes”. Say that instead.
- name: Check whether the ruleset would load
ansible.builtin.command:
cmd: /usr/sbin/nft -c -f /etc/nftables.conf
register: nft_check
changed_when: false
failed_when: nft_check.rc not in [0, 1]The difference is total: ignore_errors discards every failure
including the ones you have not thought of, while failed_when names
the acceptable outcomes and leaves everything else fatal.
For the “stop an already-stopped service” case that starts most of these, the answer is usually neither keyword — a module with proper state semantics does not fail when the desired state already holds.
2. block/rescue — compensate, then decide
- name: Deploy with a compensating action
block:
- name: Deploy the new artefact
ansible.builtin.unarchive:
src: 'https://artifacts.example.com/app-{{ app_version }}.tar.gz'
dest: /srv/app
remote_src: true
- name: Start the application
ansible.builtin.systemd_service:
name: app
state: started
rescue:
- name: Restore the previous artefact
ansible.builtin.command:
cmd: /srv/app/bin/rollback-to-previous
changed_when: true
- name: Fail the run deliberately, having compensated
ansible.builtin.fail:
msg: >-
Deploy of {{ app_version }} failed on {{ inventory_hostname }};
previous version restored. Run not marked successful.The final fail is the part that matters and the part usually omitted.
A rescue block that ends without failing marks the task as rescued
and the play continues — which is correct when the compensation genuinely
resolved the situation, and a quieter version of this anti-pattern when
it did not.
3. Let it fail
Often the right answer. If the task failing means the change did not happen, the run should stop and the operator should know. Part XXIII’s argument in one line: a run that stops halfway is recoverable; a run that reports success having done half the work is not, because nobody knows to recover it.
Finding them
cd /srv/ansible
grep -rn 'ignore_errors:\s*\(true\|yes\|True\)' --include='*.yml' --include='*.yaml' .
grep -rn 'ignore_unreachable:\s*\(true\|yes\|True\)' --include='*.yml' --include='*.yaml' .
# Recaps from previous runs, if you log them.
grep -h 'ignored=[1-9]' /var/log/ansible/*.log | tail -20- Rank by what the ignored task does. An ignored service restart or migration is a different order of problem from an ignored optional file deletion.
- For each one, ask what specific failure it was added to suppress. If nobody knows, that is the finding.
- Replace with failed_when where the answer is "this exit code is fine", which is most of them.
- Replace with block/rescue where a compensating action exists - and check the rescue ends in a deliberate fail unless it genuinely resolved the problem.
- Delete the rest and let the tasks fail. Then fix what fails, which is the work that was deferred when the keyword was added.
- Add a CI check that fails on new ignore_errors outside an allowlist, so the count cannot silently grow again.
Knowledge check
Knowledge check · 4 questions
Q1. A nightly play runs with `ignore_unreachable: true` at play level. A network partition makes 40 hosts unreachable. What does the recap and exit code show?
Q2. Which are honest replacements for a blanket ignore_errors? Select all that apply.
Q3. A task with ignore_errors: true has its result discarded, so a registered variable from it cannot be tested afterwards.
Q4. Removing ignore_errors from a repository makes the nightly pipeline fail every night. What is the right response?
Passing score: 75%. Answers are checked in this browser.