Ansible Error Handling: Production Patterns from a 3AM PagerDuty
Master Ansible error handling with ignore_errors, failed_when, block/rescue/always, any_errors_fatal, and max_fail_percentage.
20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.
- ✓Solid grasp of DevOps fundamentals
- ✓Comfortable with command-line tools
- ✓Basic Linux administration knowledge
Ansible error handling uses ignore_errors, failed_when, and block/rescue/always to control playbook execution on failure. The key is to avoid masking real failures: use failed_when to define custom failure conditions, and reserve ignore_errors only for non-critical tasks where failure is acceptable and expected.
Ansible error handling refers to the mechanisms that control how Ansible responds when a task fails or returns an unexpected status. By default, Ansible stops executing tasks on a host if any task fails (the any_errors_fatal behavior at the play level is off, but per-host it stops).
However, production playbooks need fine-grained control: you might want to ignore certain failures, define custom failure conditions based on command output, or override the 'changed' status. The core directives are ignore_errors, failed_when, changed_when, and the block/rescue/always pattern.
Additionally, any_errors_fatal and max_fail_percentage control play-level failure propagation. These tools allow you to build resilient automation that handles edge cases gracefully, without masking real issues.
Imagine you're a chef cooking a complex multi-course meal. Your recipe (the Ansible playbook) has steps like 'chop onions' and 'sear steak.' If you burn the onions (a task fails), you have a few options: you can ignore it and move on (ignore_errors), or you can decide that burnt onions are actually a failure only if they're black (failed_when). You might also want to know if the steak is 'changed' only when it's actually cooked differently (changed_when). For risky sequences, like reducing a sauce, you might use a 'try-catch' approach: try the reduction, if it fails, rescue by adding a thickener, and always clean up the pan (block/rescue/always). In a busy kitchen, if one station fails, you might want to stop the whole service (any_errors_fatal) or only if too many stations fail (max_fail_percentage). This article teaches you these patterns so your automated kitchen runs smoothly.
It was 3 AM, and my phone was buzzing with PagerDuty alerts. Our Ansible-driven deployment had taken down half the production fleet. The playbook had a task that checked for a lock file; if it existed, the task failed, and Ansible stopped the entire play. The problem? A stale lock file from a previous deployment that should have been ignored. We had ignore_errors set, but a junior engineer had commented it out during a code review, thinking it was dead code. The result: every host that had that lock file failed, and our rolling update aborted after the first batch. We lost 30 minutes of uptime. That night, I learned that error handling in Ansible isn't just about preventing failures—it's about defining what failure means for your system.
ignore_errors: When to Use and When to Avoid
The ignore_errors directive tells Ansible to continue executing tasks on a host even if the current task fails. It's a blunt instrument. Use it for non-critical checks, like verifying a service is running where you have a fallback. Never use it to hide real failures—it masks the error and still marks the task as 'failed' in output (with ...ignoring). A better pattern is to use failed_when with a condition that never matches, but that's an anti-pattern. Real example: checking for a lock file before deployment:
``yaml - name: Check for deployment lock file ansible.builtin.stat: path: /var/lock/deploy.lock register: lock_check ignore_errors: yes ``
If the lock file exists, this task fails (if the stat module fails on permission? Actually stat doesn't fail on missing file, it returns exists: false. So ignore_errors is redundant here. Better: use failed_when: false but that's weird. The point: only use ignore_errors when the task's failure is acceptable and you have subsequent logic to handle it. In production, we once had a task that stopped a service that might already be stopped; we used ignore_errors. But then a real failure (e.g., service not found) was ignored, causing a cascading issue. We switched to failed_when: result.rc != 0 and 'not running' not in result.stderr.
--- - name: Use ignore_errors sparingly hosts: web tasks: - name: Attempt to stop a service that may not exist ansible.builtin.service: name: "{{ item }}" state: stopped loop: - nginx - apache2 ignore_errors: yes # Only because we expect some services might be missing register: result - name: Show failures that were ignored ansible.builtin.debug: msg: "Failed to stop {{ item.item }}: {{ item.msg }}" loop: "{{ result.results }}" when: item.failed
mount command. It failed when the mount wasn't present. We used ignore_errors, but then the mount actually failed due to a bad filesystem, and we didn't notice. We changed to register the output and use failed_when.failed_when: Defining Custom Failure Conditions
failed_when overrides Ansible's default failure detection. You provide a Jinja2 expression that evaluates to true when the task should be considered failed. This is essential for commands that return non-zero on success (e.g., grep returning 1 for no match) or for complex checks based on stdout/stderr. Syntax:
``yaml - name: Run custom script ansible.builtin.shell: /usr/local/bin/check_health.sh register: health_result failed_when: health_result.rc != 0 or 'CRITICAL' in health_result.stdout ``
Common gotcha: failed_when is evaluated after the task runs. If the task fails before running (e.g., invalid parameters), failed_when is not evaluated. Also, failed_when and ignore_errors interact: if both are set, ignore_errors takes precedence, but the task is still marked failed if failed_when is true, then ignored. To truly override, set failed_when: false (though that's odd). Production tip: always test your failed_when condition with a known failure case. We once had failed_when: result.rc == 1 but the command returned 2 for a different error; we missed a failure.
--- - name: Define custom failure conditions hosts: web tasks: - name: Check if application is healthy ansible.builtin.uri: url: http://localhost:8080/health return_content: yes register: health_check failed_when: - health_check.status != 200 - '"healthy" not in health_check.content'
and/or to combine conditions. Example: failed_when: (result.rc != 0) or ('ERROR' in result.stderr).'FAIL' in result.stdout. Saved us from a corrupted database.changed_when: Preventing False Changes
changed_when controls whether a task reports 'changed' or 'ok'. By default, Ansible considers a task 'changed' if it modifies state (e.g., file module, command module if not idempotent). For commands that always return 'changed' (like shell with a script that always reports success), you can override:
``yaml - name: Run idempotent script ansible.builtin.shell: /usr/local/bin/update_cache.sh register: cache_update changed_when: cache_update.rc == 0 and 'updated' in cache_update.stdout ``
If you want a task to never report changed, use changed_when: false. This is common for read-only checks. However, be careful: if a task that should change things never reports changed, you lose audit trail. In production, we had a task that restarted a service only if a config file changed; we used changed_when: config_changed where config_changed was a registered variable. This gave accurate change tracking.
--- - name: Prevent false changes hosts: web tasks: - name: Run a script that always returns changed ansible.builtin.shell: /usr/local/bin/deploy.sh register: script_result changed_when: '"deployed" in script_result.stdout'
changed_when: false and then used a separate task to detect actual changes. This reduced noise in our deployment logs.block/rescue/always: The Try-Catch-Finally of Ansible
The block/rescue/always pattern provides structured error handling for a group of tasks. block contains the main tasks. If any task in the block fails, the rescue block executes. The always block runs regardless of success or failure. This is perfect for cleanup operations:
``yaml - name: Deploy application block: - name: Pull latest image ansible.builtin.docker_image: name: myapp:latest source: pull - name: Start container ansible.builtin.docker_container: name: myapp image: myapp:latest state: started rescue: - name: Rollback to previous image ansible.builtin.docker_image: name: myapp:previous source: pull - name: Notify team ansible.builtin.uri: url: https://hooks.slack.com/services/... method: POST body: '{"text":"Deployment failed, rolled back"}' always: - name: Clean up temp files ansible.builtin.file: path: /tmp/deploy_temp state: absent ``
Important: variables set in block are available in rescue and always. However, if a task in rescue fails, the entire play fails (unless you handle it). Use ignore_errors in rescue if needed. Also, rescue does not run if a task in block fails due to syntax error or unreachable host—only task execution failures.
--- - name: Block/rescue/always pattern hosts: web tasks: - name: Attempt deployment and handle failure block: - name: Deploy application ansible.builtin.copy: src: /tmp/app.war dest: /opt/tomcat/webapps/app.war notify: restart tomcat rescue: - name: Rollback on failure ansible.builtin.copy: src: /opt/backups/app.war dest: /opt/tomcat/webapps/app.war notify: restart tomcat always: - name: Clean up temp files ansible.builtin.file: path: /tmp/app.war state: absent
any_errors_fatal: Stop the Play on Any Failure
By default, if a task fails on a host, Ansible stops executing further tasks on that host but continues on other hosts. Setting any_errors_fatal: true changes this: if any task fails on any host, the entire play stops immediately for all hosts. This is useful when a failure on one host indicates a systemic issue that should halt the entire deployment. Use it sparingly, as it can cause unnecessary downtime.
``yaml - name: Deploy critical update hosts: all any_errors_fatal: true tasks: - name: Validate config ansible.builtin.shell: /usr/local/bin/validate_config.sh ``
In production, we used this for a security patch that had to be applied consistently across all hosts. If one host failed validation, we wanted to stop and investigate. However, we combined it with serial: 1 to limit blast radius. A common mistake is setting any_errors_fatal: true without serial, causing all hosts to fail if one has a transient issue.
--- - name: Stop play on any failure hosts: web any_errors_fatal: true tasks: - name: Critical config update ansible.builtin.template: src: app.conf.j2 dest: /etc/app/app.conf notify: restart app
serial: 1 or a small batch size with any_errors_fatal: true to avoid taking down the entire fleet on a single failure.max_fail_percentage: Graceful Degradation in Rolling Updates
max_fail_percentage is a play-level directive that sets the maximum percentage of hosts that can fail before Ansible aborts the entire play. It's typically used with serial for rolling updates. For example:
``yaml - name: Rolling update hosts: webservers serial: 5 max_fail_percentage: 20 tasks: - name: Update app ansible.builtin.yum: name: myapp state: latest ``
If more than 20% of the hosts in a batch fail, the play stops. This prevents a bad deployment from taking down too many hosts. The percentage is calculated per batch, not globally. If you have 5 hosts per batch and 2 fail (40%), that exceeds 20%, so the play stops. Important: max_fail_percentage defaults to 0 (abort on any failure) if not set? Actually, default is max_fail_percentage: 0 meaning abort on any failure? No, default is no limit. Check docs: if not set, there's no limit. So setting it to 0 means abort on any failure? Actually, from Ansible docs: 'The maximum percentage of hosts that can fail before the play is aborted. If not set, the play will not abort regardless of failures.' So 0 means abort on any failure. To allow some failures, set a positive integer. In production, we use 20% for rolling updates to tolerate transient issues.
--- - name: Rolling update with max fail percentage hosts: web serial: 2 max_fail_percentage: 25 tasks: - name: Update application ansible.builtin.yum: name: myapp state: latest register: result until: result is succeeded retries: 3 delay: 5
Using Rescue to Notify and Clean Up After Failures
The rescue block is not just for rollback; it's also for notification and cleanup. You can use ansible.builtin.uri to call webhooks, ansible.builtin.mail to send emails, or ansible.builtin.slack (community.general) to notify teams. For cleanup, use ansible.builtin.file to remove temporary files, or ansible.builtin.service to stop services. Example:
``yaml - name: Deploy with notification block: - name: Deploy app ansible.builtin.copy: src: /tmp/app.war dest: /opt/tomcat/webapps/ - name: Restart tomcat ansible.builtin.service: name: tomcat state: restarted rescue: - name: Notify failure ansible.builtin.uri: url: "https://hooks.slack.com/services/T00/B00/xxx" method: POST body_format: json body: text: "Deployment failed on {{ inventory_hostname }}" ignore_errors: yes - name: Clean up deployed file ansible.builtin.file: path: /opt/tomcat/webapps/app.war state: absent ignore_errors: yes always: - name: Remove temp files ansible.builtin.file: path: /tmp/deploy_temp state: absent ``
Note the `ignore_errors: yes` on rescue tasks: if the notification fails, you don't want that to compound the failure. Also, the always block runs even if rescue fails. This pattern is essential for maintaining observability and cleanliness in production.
--- - name: Rescue and notify hosts: web tasks: - block: - name: Deploy new version ansible.builtin.copy: src: /tmp/app.jar dest: /opt/app/app.jar notify: restart app rescue: - name: Notify team of failure ansible.builtin.slack: token: 'T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX' msg: "Deployment failed on {{ inventory_hostname }}" delegate_to: localhost - name: Rollback ansible.builtin.copy: src: /opt/backups/app.jar dest: /opt/app/app.jar notify: restart app
Combining Error Handling Directives: A Production Pattern
In real playbooks, you'll combine multiple directives. Here's a pattern for a rolling update with error handling:
``yaml - name: Rolling update with error handling hosts: webservers serial: 10 max_fail_percentage: 20 any_errors_fatal: false tasks: - name: Pre-check block: - name: Check disk space ansible.builtin.shell: df / | awk 'NR==2 {print $5}' | sed 's/%//' register: disk_usage failed_when: disk_usage.stdout | int > 90 - name: Check service health ansible.builtin.uri: url: http://localhost:80/health status_code: 200 register: health ignore_errors: yes rescue: - name: Skip host and notify ansible.builtin.debug: msg: "Host {{ inventory_hostname }} failed pre-check, skipping" changed_when: false - name: Notify ansible.builtin.uri: url: https://hooks.slack.com/... method: POST body: '{"text":"Pre-check failed on {{ inventory_hostname }}"}' ignore_errors: yes always: - name: Log check result ansible.builtin.copy: content: "{{ disk_usage.stdout }}" dest: /var/log/precheck.log ignore_errors: yes ``
This pattern checks prerequisites, skips the host if they fail, and logs the result. The play continues with other hosts, but if too many fail, max_fail_percentage aborts. This is a robust pattern for large fleets.
--- - name: Combined error handling hosts: web any_errors_fatal: true serial: 1 max_fail_percentage: 0 tasks: - block: - name: Deploy app ansible.builtin.copy: src: /tmp/app.war dest: /opt/tomcat/webapps/app.war notify: restart tomcat register: deploy failed_when: - deploy.failed - '"disk full" in deploy.msg' rescue: - name: Rollback ansible.builtin.copy: src: /opt/backups/app.war dest: /opt/tomcat/webapps/app.war notify: restart tomcat always: - name: Cleanup ansible.builtin.file: path: /tmp/app.war state: absent
Testing Error Handling: CI/CD Patterns
Error handling code is only as good as its test coverage. In CI, create test playbooks that intentionally fail to verify your error paths. Use ansible-playbook --syntax-check to catch syntax errors. For logic testing, use ansible-playbook --check --diff to see what would change. But for error handling, you need to actually trigger failures. We use molecule with scenarios that simulate failures:
``yaml # molecule/default/verify.yml - name: Verify error handling hosts: all tasks: - name: Trigger failure ansible.builtin.command: /bin/false register: result failed_when: result.rc != 0 ``
Then assert that the rescue block ran. Another pattern: use ansible.builtin.fail module in test plays. For example, to test max_fail_percentage, run a playbook with multiple hosts and force failures on some. Use ansible-playbook --limit to target specific hosts. Also, use -v flags to see error handling output: -vvv shows failed_when evaluation. In production, we have a CI pipeline that runs a dedicated 'chaos' playbook that injects failures to validate our error handling.
#!/bin/bash # CI/CD test for error handling ansible-playbook -i inventory.ini site.yml --check --diff 2>&1 | tee playbook_output.log if grep -q 'failed=0' playbook_output.log; then echo "Playbook check passed" else echo "Playbook check failed" exit 1 fi
failed_when because the test never triggered the failure condition. We added a step that explicitly forces the failure condition to validate the error path.Common Pitfalls with ignore_errors and failed_when Interactions
The interaction between ignore_errors and failed_when can be confusing. Key rule: ignore_errors is evaluated after failed_when. So if both are set, the task is first evaluated for failure using failed_when. If failed_when returns true, the task is marked failed, but then ignore_errors causes the play to continue. The task output still shows 'failed' with 'ignoring'. This can mislead operators. A common pitfall is setting ignore_errors: yes on a task with failed_when thinking it will suppress the failure display. It doesn't. To truly suppress, use failed_when: false and no ignore_errors. But that's an anti-pattern. Better: use register and conditionals on subsequent tasks. Example:
```yaml - name: Attempt to stop service ansible.builtin.service: name: myapp state: stopped register: stop_result ignore_errors: yes
- name: Handle failure
- ansible.builtin.debug:
- msg: "Service stop failed, continuing"
- when: stop_result is failed
- ```
This pattern is clearer than relying on ignore_errors alone. In production, we avoid ignore_errors on critical tasks; we use register and when to handle failures explicitly.
--- - name: Pitfall: ignore_errors overrides failed_when hosts: web tasks: - name: Check health with ignore_errors and failed_when ansible.builtin.uri: url: http://localhost:8080/health register: health ignore_errors: yes failed_when: health.status != 200 # This is ignored because ignore_errors is set - name: This runs even if health check failed ansible.builtin.debug: msg: "Health check status: {{ health.status }}"
Error Handling in Loops: With_items and Failed Items
When using loops (e.g., with_items, loop), a failure in one iteration stops the entire task by default. To handle per-item failures, use ignore_errors: yes on the task and then check results for failures. Example:
```yaml - name: Install packages ansible.builtin.yum: name: "{{ item }}" state: present loop: - nginx - bad-package - mysql ignore_errors: yes register: install_results
- name: Report failed packages
- ansible.builtin.debug:
- msg: "Package {{ item.item }} failed to install"
- loop: "{{ install_results.results | selectattr('failed', 'equalto', true) | list }}"
- ```
This pattern allows the play to continue and then process failures. In production, we use this for package installations where some packages might be unavailable. We then send a report of failed packages to a monitoring system.
--- - name: Error handling in loops hosts: web tasks: - name: Install multiple packages ansible.builtin.yum: name: "{{ item }}" state: present loop: - nginx - nonexistent_pkg - httpd register: result ignore_errors: yes - name: Show failed items ansible.builtin.debug: msg: "Failed to install {{ item.item }}: {{ item.msg }}" loop: "{{ result.results }}" when: item.failed
loop_control with pause to throttle, but for error handling, register the results and filter.Error Handling Best Practices for Production Playbooks
- Always register results for tasks that can fail, even if you use ignore_errors. This allows debugging later.
- Use failed_when instead of ignore_errors when you have specific failure criteria.
- Limit any_errors_fatal to critical deployments; use max_fail_percentage for rolling updates.
- Test error paths in CI by forcing failures.
- Document error handling decisions in comments, especially why a task is ignored.
- Use block/rescue/always for any multi-step operation that needs cleanup.
- Avoid nested blocks; they complicate error handling.
- Set changed_when: false on read-only tasks to avoid false change notifications.
- Use ansible.builtin.fail in rescue blocks to re-raise failures after cleanup if needed.
- Monitor for ignored failures; they can hide real issues. Use a post-play hook to check for ignored tasks.
Example of a post-play hook:
``yaml - name: Check for ignored failures ansible.builtin.fail: msg: "There were {{ ignored_count }} ignored failures" when: ignored_count | default(0) > 0 vars: ignored_count: "{{ ansible_failed_result | selectattr('ignored', 'equalto', true) | list | length }}" ``
This is a simplified example; in practice, you'd need to aggregate across hosts.
--- - name: Best practices for production hosts: web gather_facts: yes vars: deploy_version: "1.2.3" tasks: - name: Validate prerequisites ansible.builtin.assert: that: - deploy_version is defined - deploy_version is version('1.0.0', '>=') fail_msg: "Deploy version must be >= 1.0.0" - name: Deploy with retries block: - name: Deploy application ansible.builtin.get_url: url: "https://example.com/app/{{ deploy_version }}/app.war" dest: /opt/tomcat/webapps/app.war register: download until: download is succeeded retries: 3 delay: 5 rescue: - name: Notify on failure ansible.builtin.debug: msg: "Deployment failed after retries" - name: Fail play ansible.builtin.fail: msg: "Deployment failed"
ansible-lint on your playbooks; it can detect missing error handling or misused directives.The Stale Lock File Incident
ignore_errors: yes removed during a refactor, and any_errors_fatal was set to true on the play. The lock file was stale but harmless.ignore_errors: yes to the lock file check task, and changed any_errors_fatal to false. Also added a rescue block to delete the lock file if the deployment failed.- Never assume a failure is safe; explicitly declare error handling intent.
- Use ignore_errors for non-critical checks, and always test with a stale state.
ignore_errors: yes is set. If not intended, remove it. If intended, verify the task's failure condition is correct.failed_when condition. It might be evaluating to true on success. Example: failed_when: result.rc != 0 but command returns 1 on success. Fix by adjusting condition.changed_when: false or set a condition like changed_when: result.rc == 0 if the command always returns 0.serial being setany_errors_fatal: true is set on the play. Set it to false or remove it. Also check for max_fail_percentage: 0 which acts similarly.ansible-playbook playbook.yml --checkansible-playbook playbook.yml -vvv | grep 'failed_when'ansible-playbook playbook.yml --syntax-checkansible-playbook playbook.yml --diffgrep -r 'any_errors_fatal' playbook.ymlansible-playbook playbook.yml --list-tasksgrep 'max_fail_percentage' playbook.ymlansible-playbook playbook.yml --check --limit batch1| Directive | Scope | Effect on Failure | Use Case |
|---|---|---|---|
| ignore_errors | Task | Continues play, marks task as 'failed...ignoring' | Non-critical checks, e.g., optional service stop |
| failed_when | Task | Overrides failure condition | Commands with non-standard exit codes |
| changed_when | Task | Overrides changed status | Idempotent scripts that always report success |
| block/rescue/always | Block | Try-catch-finally for task group | Multi-step operations needing rollback/cleanup |
| any_errors_fatal | Play | Stops entire play on any failure | Critical deployments where consistency is mandatory |
| max_fail_percentage | Play | Aborts if failure % exceeds threshold | Rolling updates with tolerance for transient failures |
| File | Command / Code | Purpose |
|---|---|---|
| site.yml | - name: Use ignore_errors sparingly | ignore_errors |
| site.yml | - name: Define custom failure conditions | failed_when |
| site.yml | - name: Prevent false changes | changed_when |
| site.yml | - name: Block/rescue/always pattern | block/rescue/always |
| site.yml | - name: Stop play on any failure | any_errors_fatal |
| site.yml | - name: Rolling update with max fail percentage | max_fail_percentage |
| site.yml | - name: Rescue and notify | Using Rescue to Notify and Clean Up After Failures |
| site.yml | - name: Combined error handling | Combining Error Handling Directives |
| ci_test.sh | ansible-playbook -i inventory.ini site.yml --check --diff 2>&1 | tee playbook_ou... | Testing Error Handling |
| site.yml | - name: Pitfall: ignore_errors overrides failed_when | Common Pitfalls with ignore_errors and failed_when Interacti |
| site.yml | - name: Error handling in loops | Error Handling in Loops |
| site.yml | - name: Best practices for production | Error Handling Best Practices for Production Playbooks |
Key takeaways
Interview Questions on This Topic
What is the difference between ignore_errors and failed_when?
How does block/rescue/always work in Ansible? Provide an example.
What is the purpose of any_errors_fatal and max_fail_percentage?
How can you handle failures in a loop (with_items) without stopping the entire task?
What is a common pitfall when using rescue blocks for cleanup?
Explain the interaction between ignore_errors and failed_when.
How can you test error handling in Ansible playbooks?
What is the default value of max_fail_percentage and how does it behave?
Frequently Asked Questions
Yes, but ignore_errors only allows the play to continue; the task still shows as failed. Use register and when for conditional logic instead.
No, rescue only catches task execution failures. Failures due to unreachable hosts, privilege escalation, or syntax errors are not caught.
The play will fail, potentially masking the original error. Add ignore_errors: yes to non-critical rescue tasks.
Use block/rescue/always. In rescue, use meta: end_host to end the host's tasks, or simply skip further tasks. The play will continue with other hosts.
any_errors_fatal stops the play immediately on any failure, regardless of percentage. max_fail_percentage: 0 also stops on any failure, but it's calculated per batch; if you don't use serial, it behaves similarly.
changed_when is a task-level directive; it can be used inside block, but not on the block itself. The block's changed status is derived from its tasks.
Use ansible.builtin.fail module at the end of the rescue block with a message. This will cause the play to fail after cleanup.
No, error handling is per-task or per-play. You can use a custom plugin or include tasks with common error handling, but there's no global directive.
20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.
That's Ansible. Mark it forged?
8 min read · try the examples if you haven't