Courseiva
EX294Chapter 15 of 18Objective 6.1

Error Handling and Troubleshooting Playbooks

What do you do when your Ansible playbook fails halfway through a task on 50 servers? Without error handling, you have a mess — some servers updated, some broken, and no way to roll back cleanly. This chapter gives you the exact tools to manage failures gracefully, so you can pass EX294 and keep your infrastructure stable in the real world.

12 min read
Advanced
Updated Jul 24, 2026
Editorial oversight: Johnson Ajibi· Security Systems Architect · MSc IT Security · IEEE Senior Member

A simple way to picture Error Handling and Troubleshooting Playbooks

The Flat-Pack Furniture Assembly Analogy

Have you ever tried to assemble a flat-pack wardrobe, only to find the final panel doesn't fit because you missed a crucial step early on?

That is precisely what troubleshooting an Ansible playbook feels like. Imagine you are following a detailed instruction booklet (the playbook). Each instruction is a task: 'Attach the left side panel to the base', 'Screw in the cam lock', 'Fit the back panel'. If you skip a step or do it in the wrong order, the whole thing falls apart. But the real nightmare is when the wood splinters (a server crashes) or you are missing a screw (a required package isn't installed). You do not want to tear the whole wardrobe apart to fix one mistake. You want to pause, see exactly which step caused the problem, and decide what to do — skip it, fix it, or stop completely.

Ansible gives you tools to do exactly that. You can set 'ignore_errors' to keep going if a minor issue happens, like a slightly misaligned screw hole. You can use 'failed_when' to say, 'If the wood cracks, that is not a failure, just keep going.' Or 'any_errors_fatal' to stop everything if the main support beam breaks. And when the whole thing goes wrong, 'rescue' and 'always' let you backtrack (undo a wrong step) or finish with a cleanup, like sweeping up sawdust before walking away. This chapter teaches you those tools so your playbook assembly never leaves you with a wobbly heap of code.

How It Actually Works

Ansible is a tool that automates IT tasks by running a series of instructions called 'tasks' in a 'playbook'. A playbook is a YAML file — a list of instructions written in a human-readable format using indentation. When you run a playbook, Ansible connects to your target servers (called 'managed nodes') using SSH and applies each task one by one. But tasks can fail. A server might be unreachable, a package might not install, or a command might return an unexpected error. Without error handling, a failed task stops the entire playbook immediately, leaving some servers partially configured and others untouched. That is bad.

Error handling in Ansible is the set of keywords and techniques you add to your playbook to control what happens when a task fails. The core keywords are 'ignore_errors', 'failed_when', 'changed_when', 'any_errors_fatal', 'max_fail_percentage', and the block-level keywords 'block', 'rescue', and 'always'. Let us break each down.

'ignore_errors' is the simplest: you set it to 'yes' on a task, and even if that task fails, Ansible continues to the next task. You use this when a failure is not critical — for example, trying to delete a file that might not exist. Without 'ignore_errors', the playbook would crash. With it, it moves on. But you must be careful: blindly ignoring errors can mask real problems.

'failed_when' is more precise. It lets you define a condition that makes a task be considered a failure. For instance, a command might run but return output you do not expect. By default, Ansible only marks a task as failed if the command exits with a non-zero return code. But sometimes a command exits with code 0 (success) even when it did not do what you wanted. You use 'failed_when' to say: 'Mark this task as failed if the output contains the word ERROR.' This gives you control over what counts as failure.

'changed_when' works similarly but for the 'changed' status. Ansible automatically reports a task as 'changed' if it modifies the system — installing a package, creating a file. But some commands do not report changes correctly. You use 'changed_when' to override that, setting your own condition. For example, 'changed_when: false' tells Ansible to never mark this task as changed, even if it did something.

'any_errors_fatal' is a play-level keyword (you put it at the start of the play, not on individual tasks). If you set 'any_errors_fatal: yes', then if *any* task fails on *any* host in the play, Ansible stops executing on *all* hosts immediately. This is crucial when a failure on one server means the entire process is compromised — like if a database schema migration fails, you do not want other servers to keep running.

'max_fail_percentage' is similar but softer. You set a percentage — for example, 'max_fail_percentage: 25'. If the number of failed hosts exceeds that percentage of the total hosts, Ansible stops the play. This is useful for rolling updates where you can tolerate a few failures but not many.

The most powerful error handling structure is the 'block/rescue/always' block. This is like a 'try/catch/finally' in programming languages. You group tasks inside a 'block'. If any task in the block fails, execution jumps to the 'rescue' section, where you define tasks that handle the failure — like rolling back a change or logging the error. After the 'rescue' section finishes, execution goes to the 'always' section, which runs regardless of success or failure. You use 'always' for cleanup tasks — stopping a service, removing temporary files.

Here is a simple example. You want to install a web server and start it, but if the installation fails, you want to stop any partially installed services. Your playbook might look like this: 'block' contains 'install nginx' and 'start nginx'. If either fails, 'rescue' runs 'stop any nginx process' and 'notify admin'. Then 'always' runs 'clean up temp files'. This structure keeps your playbook robust.

Why does error handling exist? Without it, automation is brittle. One typo in a configuration file can break a deployment across hundreds of servers. Error handling gives you resilience. It replaces manual, error-prone processes where a human has to watch every step and intervene. In the old days, sysadmins would run scripts that stopped at the first error, leaving the system in an inconsistent state. Then they had to manually inspect each server. Ansible's error handling automates that recovery logic.

For the EX294 exam, you must know how to write these keywords in YAML syntax. You need to understand that 'ignore_errors' is a task-level keyword, 'any_errors_fatal' is play-level, and 'block/rescue/always' is a way to group tasks. You also need to know that 'rescue' and 'always' can only be used when you have a 'block' — they are not standalone. You will be tested on which keyword to use in a given scenario: 'If a task fails, stop everything' — that is 'any_errors_fatal: yes'. 'If a task fails, skip it and continue' — that is 'ignore_errors: yes'. 'Define custom failure conditions' — that is 'failed_when'.

Finally, troubleshooting playbooks is a separate but related skill. Common issues include syntax errors in YAML (bad indentation, missing colons), incorrect module names, wrong variable references, or missing required arguments. Ansible gives you tools to debug: the '--syntax-check' flag validates your YAML before running, the '--check' flag does a dry run without making changes, and the 'ansible-playbook' command with '-v' (verbose) gives you detailed output. You can also use the 'debug' module to print variable values during execution. For the exam, you must be able to read error messages and identify the problem — for example, a 'MODULE_FAILURE' means the module itself crashed, while 'UNREACHABLE' means Ansible could not connect to the host.

Flowchart showing how Ansible processes a task failure: depending on the error handling keywords, it may continue, run rescue/always blocks, or stop all hosts.

Walk-Through

1

Identify Your Failure Scenario

Before writing any error handling, decide what kind of failure you expect. Is it a host being unreachable? A command returning a non-zero exit code? A task that needs to be run 5 times before it succeeds? This decision determines which error handling keyword to use — 'ignore_unreachable' for network issues, 'failed_when' for custom failure conditions, or 'retry' for transient failures.

2

Write the Play and Task

Create your playbook structure with the play-level directives (hosts, become, vars) and the list of tasks. For each task, specify the module and its arguments. For example, a task to install a package might be: 'ansible.builtin.apt: name=nginx state=present'. This is the baseline before adding error handling.

3

Decide Task-Level Error Handling

For each task, decide if it should have 'ignore_errors', 'ignore_unreachable', 'failed_when', or 'changed_when'. Add the keyword under the task with appropriate syntax. For example, if the task is to delete a file that might not exist, add 'ignore_errors: yes' to avoid the playbook stopping on a missing file.

4

Implement Play-Level Error Control

At the play level (above the tasks list), add 'any_errors_fatal: yes' if you want a single failure anywhere to stop everything, or 'max_fail_percentage: <number>' to tolerate a certain failure rate. Also consider 'force_handlers: yes' to run handlers on failure. These controls affect all tasks in the play.

5

Group Tasks with block/rescue/always

Wrap related tasks inside a 'block' directive. Below the 'block', add a 'rescue' section containing tasks to run if any task in the 'block' fails. Below the 'rescue', add an 'always' section with cleanup tasks. For example, if you install a service in the block and it fails, the rescue might uninstall it, and the always might delete temp config files.

6

Test the Playbook

Run the playbook with '--syntax-check' first to catch YAML errors. Then run it in '--check' mode (dry run) to see what would change without actually executing. Finally, run it for real on a test host. Use '-v', '-vv', or '-vvv' to see detailed output. Look for 'ok', 'changed', 'failed', and 'unreachable' statuses. If a task fails, examine the error message to see if your error handling worked correctly.

7

Review and Refine

After the test run, review which tasks succeeded or failed. Check if the 'rescue' or 'always' sections ran as expected. Adjust your error handling if needed — maybe you need a custom 'failed_when' condition because a task succeeded with a warning that you want to treat as a failure. Repeat the test cycle until the playbook behaves as desired.

What This Looks Like on the Job

Imagine you work for an e-commerce company that runs its web servers on a fleet of 200 Linux machines. Every Tuesday at 2 a.m., you run an Ansible playbook to deploy a new version of the application. The playbook updates packages, copies configuration files, restarts the web server, and runs a health check. In the real world, things go wrong. A networking issue might mean 5 servers are unreachable. A package repository might be down, causing an installation to fail on 10 servers. A configuration file might have a typo that the web server rejects.

Without error handling, here is what happens: the playbook runs, hits an unreachable host, and stops. The other 199 servers never get the update. You have to fix the network issue, then rerun the entire playbook. But some servers already had partial updates from the first run, leading to version mismatches. This is a nightmare.

With error handling, you can design the playbook to handle each scenario. - For network blips: you set 'ignore_unreachable: yes' at the play level (a newer keyword that lets you continue if a host is temporarily unreachable). Ansible skips those hosts and keeps deploying to the rest. - For package failures: you use a 'block/rescue' structure. In the 'block', you install the package and restart the service. In the 'rescue', you roll back the package if it partially installed, and log the failure to a file. - For configuration errors: you use 'failed_when' on the health check task. If the health check returns an HTTP 500 status code, you mark the task as failed. Then the 'rescue' block restores the previous configuration file from a backup. - You also set 'max_fail_percentage: 10' so that if more than 20 servers fail, the entire deployment stops and triggers an alert to the operations team. This prevents a bad deployment from affecting too many servers. - After the playbook finishes, you use the 'always' block to run a cleanup task that removes temporary files used during the update.

An IT professional also uses troubleshooting techniques daily. Before running a playbook on production, they run it with '--check' mode to see what would change. They use '--step' mode to confirm each task before execution. When a playbook fails, they increase verbosity with '-vvv' to see the exact SSH commands and responses. They use the 'debug' module to print variable values at critical points. They check log files on the server, and they look at the 'ansible_failed_result' variable which stores the error message from a failed task.

In a real business, this structured error handling saves hours of manual work and reduces downtime. It also makes the deployment auditable — you can see exactly which tasks failed and what recovery actions were taken. For the EX294 exam, you must understand this practical application because the exam questions often describe a scenario like 'a deployment to 100 servers where 5 fail' and ask which keyword to use.

How EX294 Actually Tests This

The EX294 exam, for objective 6.1, tests your ability to write and troubleshoot playbooks that handle errors. The exam is heavily practical — you will be given a scenario and asked to select the correct Ansible keyword or write a short playbook snippet. You need to memorise the exact syntax for each error handling keyword because the exam uses multiple-choice and fill-in-the-blank questions.

Here are the specific concepts they love to test:

'ignore_errors' vs 'ignore_unreachable': Many beginners confuse these. 'ignore_errors' ignores any failure from the task's execution. 'ignore_unreachable' specifically ignores when the host cannot be connected to. The exam sets traps where they give a scenario about a host being offline, and the options include 'ignore_errors: yes' — that is wrong, because 'ignore_errors' does not catch unreachable hosts unless you also set 'ignore_unreachable'. The correct answer is 'ignore_unreachable: yes'.

'any_errors_fatal' placement: It must be set at the play level, not the task level. The exam will show a task with 'any_errors_fatal: yes' indented under a task, which is invalid. You need to know it goes under the 'hosts' line in the play.

'failed_when' condition syntax: You must know how to write the condition. Common patterns: 'failed_when: result.rc != 0' (check return code) or 'failed_when: result.stdout is not search("SUCCESS")' (check output). The exam often provides a task and asks you to choose the correct 'failed_when' line.

'block/rescue/always' hierarchy: The 'block' is required before 'rescue' and 'always'. They cannot exist without a 'block'. The exam might present a snippet with 'rescue' but no 'block' and ask if it is valid — answer: no.

'max_fail_percentage' interpretation: They test that this is a percentage of the total hosts in the play, not the number of hosts. For example, if you have 20 hosts and 'max_fail_percentage: 25', the play stops if 6 hosts fail (25% of 20 = 5, so 6 exceeds). They might ask: 'If 5 out of 20 hosts fail with a max_fail_percentage of 25, does the play stop?' The answer is no, because 5 is exactly 25%, not more.

Common traps the exam sets:

They give a playbook with 'ignore_errors: yes' on a task, but the play also has 'any_errors_fatal: yes'. The correct behaviour is that 'any_errors_fatal' overrides 'ignore_errors' — if a task fails, the play still stops because 'any_errors_fatal' is checked after the task. You must understand the precedence.

They use 'failed_when' on a task that also has 'changed_when'. Both can coexist, but you need to know that 'failed_when' determines failure, 'changed_when' determines the 'changed' status. These are independent.

They test the 'force_handlers' keyword — a play-level keyword that forces handler tasks (tasks that run on 'notify') to run even if the play fails. This is a subtle trick. Many beginners forget it. The exam may ask: 'Which keyword ensures handlers run on failure?' Answer: 'force_handlers: yes'.

They give a scenario with multiple tasks and ask which tasks run after a failure inside a 'block'. The answer: tasks in the 'rescue' section run, then tasks in the 'always' section. Tasks after the 'block' in the same play do not run unless the 'block' succeeds.

They test the difference between 'failed_when' and 'failed_when: false'. Setting 'failed_when: false' means the task never fails, which is different from 'ignore_errors: yes'. The exam expects you to know this nuance.

To prepare, you should memorise the exact YAML syntax for each keyword. Practise writing small playbooks that use 'block/rescue/always'. Know that 'always' runs even if the task in 'block' succeeds, and even if 'rescue' fails. Also, understand that error handling is not just for failures — it also covers when a task changes state unexpectedly. Finally, the exam will include troubleshooting questions: given an error message, identify the cause. Common error messages include 'ERROR! Syntax Error' (YAML indentation), 'UNREACHABLE!' (host unreachable), 'FAILED!' (task failed), and 'MODULE FAILURE' (bug in the module). For each, you should know the likely fix — check indentation, check network, check task parameters, update the module.

Key Takeaways

'ignore_errors: yes' at the task level keeps the playbook running after a task fails, but does not handle unreachable hosts — use 'ignore_unreachable: yes' for that.

'any_errors_fatal: yes' placed at the play level stops execution on all hosts if any task fails on any host, overriding 'ignore_errors' on individual tasks.

'failed_when' defines custom failure conditions, allowing you to mark a task as failed based on its output or return code, even if the command itself exited with code 0.

The 'block/rescue/always' structure groups tasks: 'block' contains the main logic, 'rescue' runs on failure, and 'always' runs every time for cleanup — like a try/catch/finally pattern.

'max_fail_percentage' stops the play when the percentage of failed hosts exceeds the given value — it is calculated against the total hosts in the play, not the number of hosts processed so far.

Use 'ansible-playbook --syntax-check' to validate your playbook YAML before running, and use increasing verbosity levels (-v, -vv, -vvv) to troubleshoot failures by seeing SSH commands and responses.

'force_handlers: yes' at the play level ensures that handler tasks (triggered by 'notify') run even if the play fails partway through, preventing resources from being left in an inconsistent state.

When using 'debug' module for troubleshooting, you can print variables like '{{ ansible_failed_result }}' to see the exact error message of a failed task.

Easy to Mix Up

These come up on the exam all the time. Here's how to tell them apart.

ignore_errors: yes

Ignores any failure from the task and continues the playbook.

Does not change whether the task is marked as failed — it just allows continuation.

Applied as a simple boolean 'yes' or 'no'.

failed_when

Defines a condition to mark a task as failed, even if the command succeeded by default.

Can override the default failure detection — a task can be marked failed even with exit code 0.

Requires a condition expression, like 'failed_when: result.rc != 0'.

any_errors_fatal: yes

Stops all hosts immediately if any single host fails any task.

Set at the play level only.

No percentage logic — one failure is enough to stop everything.

max_fail_percentage

Stops the play only if the percentage of failed hosts exceeds a threshold.

Set at the play level with a numeric value representing percentage.

Allows a certain number of failures before stopping, useful for rolling updates.

block/rescue/always structure

Groups multiple tasks together for structured error handling.

Runs rescue tasks only on failure of any task in the block.

Includes an always section that runs regardless of success or failure.

Ignore Unreachable

Only handles the specific case of a host being unreachable.

Does not group tasks — it is a simple boolean on a task or play.

No additional sections for recovery or cleanup.

changed_when

Controls whether a task is reported as 'changed' or 'ok'.

Uses a condition to override the default 'changed' status.

Does not affect whether the task is considered a failure.

failed_when

Controls whether a task is reported as 'failed' or not.

Uses a condition to override the default failure detection.

Does not affect the 'changed' status.

Watch Out for These

Mistake

'ignore_errors: yes' will let the playbook continue even if the host is unreachable.

Correct

'ignore_errors' only ignores task execution failures, not connectivity failures. To continue when a host is unreachable, you must use 'ignore_unreachable: yes' at the task or play level.

When a host is unreachable, Ansible stops the task for that host before the task logic runs, so 'ignore_errors' never gets a chance to apply. This catch is common because many beginners only ever see task failures.

Mistake

Putting 'any_errors_fatal: yes' inside a task will stop the entire playbook if that task fails on any host.

Correct

'any_errors_fatal' is a play-level keyword and must be placed at the play level (under the 'hosts' line), not inside a task. If you put it inside a task, Ansible ignores it or throws a syntax error.

The YAML parser for plays and tasks has strict rules. Beginners often think all keywords work inside tasks because they see examples of task-level keywords like 'register'. This structural confusion leads to the mistake.

Mistake

When a task inside a 'block' fails, the tasks in 'rescue' run, and then the playbook skips the 'always' section because it already handled the failure.

Correct

The 'always' section always runs, regardless of whether the 'block' succeeded, failed, or the 'rescue' section ran. It is designed for mandatory cleanup.

This misconception comes from thinking 'always' is like a backup that only runs if things go wrong. In reality, it is a 'finally' block — it runs every time, even for success.

Mistake

If I set 'failed_when: false' on a task, it ignores any failures from that task, same as 'ignore_errors: yes'.

Correct

'failed_when: false' explicitly tells Ansible that the condition for failure is never true, so the task can never be marked as failed. It is not the same as 'ignore_errors', which allows the task to fail but continues the play. The difference matters because 'failed_when' can change the reported status.

Both prevent the playbook from stopping, but they behave differently in terms of what Ansible records. Beginners see the same end result (play continues) and assume they are equivalent.

Mistake

The 'rescue' block can be used without a 'block' if I want to handle errors for a single task.

Correct

A 'rescue' section can only exist as part of a 'block' structure. If you want error handling for a single task, you must still wrap that task in a 'block' (even if it is just one task inside it). This is a YAML structural requirement.

People are used to 'try/catch' in other languages where you can have a single statement inside the catch. In Ansible, the 'rescue' is always a child of 'block', and this strict parent-child relationship is easy to forget.

Do You Actually Know This?

Reveal each answer, then mark whether you got it right. Score 60%+ to unlock the next chapter.

Frequently Asked Questions

What is the difference between 'ignore_errors' and 'failed_when' in Ansible?

'ignore_errors: yes' tells Ansible to continue running even if the task fails, but the task is still marked as failed. 'failed_when' lets you define a custom condition that determines when a task is considered failed — it can override the default failure detection, so a task might be marked failed even if the command succeeded.

Does 'any_errors_fatal' stop the playbook for all hosts or just the failed host?

'any_errors_fatal: yes' stops execution on all hosts immediately when any task fails on any host. It is an all-or-nothing stop, not per-host. This is useful when you cannot have a partial deployment.

How do I make an Ansible task continue even if the host is unreachable?

Use 'ignore_unreachable: yes' at the task or play level. This keyword specifically handles the 'UNREACHABLE' state, which is different from a task failure. 'ignore_errors' does not cover unreachable hosts.

Can I use 'rescue' without a 'block' in Ansible?

No. The 'rescue' section can only be used as a child of a 'block' directive. If you want error handling for a single task, you must wrap that task inside a 'block' first, even if it is the only task in the block.

What does 'force_handlers: yes' do in an Ansible playbook?

'force_handlers: yes' ensures that any handlers (tasks triggered by 'notify') run even if the play fails partway through. Without it, handlers that were notified before the failure will not run, potentially leaving the system in an inconsistent state.

How do I troubleshoot a playbook that runs without errors but does not make the expected changes?

Use '--check' mode to see what Ansible thinks will change. Increase verbosity with '-vvv' to see the raw SSH commands and output. Use the 'debug' module to print variable values at key points. Check if the task is idempotent — maybe the service is already running, so Ansible reports 'ok' instead of 'changed'.

What is the correct syntax to set a custom failure condition based on command output?

Use 'failed_when' with a condition. For example, 'failed_when: result.stdout is not search("SUCCESS")' where 'result' is a variable registered from the task. You can also check return code: 'failed_when: result.rc != 0'.

Terms Worth Knowing

Keep going

You've finished Error Handling and Troubleshooting Playbooks. Continue through the EX294 study guide to build a complete picture of the exam.

Done with this chapter?