Module 03 — Playbook Control Flow
Updated 20 August 2026
Conditionals, loops, error handling, tags, imports and delegation — everything that turns a flat list of tasks into a program that reacts to what it finds.
🧠 concept → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (answers hidden)
Prerequisite: Modules 01 and 02. You already use when: and register — this module covers the full toolkit built on top of them.
Part A · Conditionals
A1 · when: — the basics
That is when: — one condition, written once, but judged separately on every single server. Which is why skipping: in the output means the decision worked, not that something failed. Reading a wall of yellow skipping lines as a problem is one of the most common beginner mistakes.
when: decides whether a task runs. It is evaluated per host, so the same task can run on web01 and skip on web02 in the same play.
- name: Install Apache on RedHat family only
ansible.builtin.package:
name: httpd
state: present
when: ansible_facts['os_family'] == "RedHat"The braces are redundant, ansible-lint flags them, and on complex expressions they can actively break evaluation. This is the single most common style error in beginner playbooks.
Operators
when: app_port == 8080 # equality
when: app_port != 8080
when: app_port > 1024 # numeric comparison
when: ansible_facts['memtotal_mb'] >= 4096
when: "'nginx' in installed_packages" # membership - note the quoting
when: "'web' in group_names" # is this host in a group?
when: app_version is version('2.0', '>=') # proper version comparisonCombining conditions
# A LIST is an implicit AND - all must be true. Preferred: it reads better in output.
when:
- ansible_facts['os_family'] == "Debian"
- ansible_facts['distribution_major_version'] is version('20', '>=')
- deploy_enabled | bool
# Explicit and / or / not
when: ansible_facts['os_family'] == "Debian" and deploy_enabled | bool
when: env == "prod" or env == "staging"
when: not maintenance_mode | bool
# Parentheses when mixing and with or - and you MUST, precedence bites otherwise
when: (env == "prod" or env == "staging") and deploy_enabled | boolTests — the is family
when: myvar is defined
when: myvar is not defined
when: myvar is none # explicitly null
when: result is succeeded # on a registered result
when: result is failed
when: result is changed
when: result is skipped
when: app_version is version('2.0', '>=')
when: path_result.stat.exists
when: some_list | length > 0
when: "'ERROR' in log_output.stdout"🧪 Exercise A1.1 — Watch a conditional evaluate per host
---
- name: Conditionals are per-host
hosts: all
gather_facts: true
tasks:
- name: Only on Debian family
ansible.builtin.debug:
msg: "{{ inventory_hostname }} is Debian family"
when: ansible_facts['os_family'] == "Debian"
- name: Only on hosts with 4GB or more
ansible.builtin.debug:
msg: "{{ inventory_hostname }} has {{ ansible_facts['memtotal_mb'] }} MB"
when: ansible_facts['memtotal_mb'] >= 4096
- name: Only on hosts in the web group
ansible.builtin.debug:
msg: "{{ inventory_hostname }} is a web server"
when: "'web' in group_names"✅ Expected result — click to reveal
TASK [Only on Debian family] ***************************************
ok: [web01] => {"msg": "web01 is Debian family"}
ok: [web02] => {"msg": "web02 is Debian family"}
skipping: [db01] <-- RedHat family
TASK [Only on hosts with 4GB or more] ******************************
ok: [web01] => {"msg": "web01 has 7936 MB"}
skipping: [web02] <-- only 2048 MB
ok: [db01] => {"msg": "db01 has 16384 MB"}
TASK [Only on hosts in the web group] ******************************
ok: [web01] => {"msg": "web01 is a web server"}
ok: [web02] => {"msg": "web02 is a web server"}
skipping: [db01]
PLAY RECAP *********************************************************
web01 : ok=4 changed=0 skipped=0
web02 : ok=3 changed=0 skipped=1
db01 : ok=2 changed=0 skipped=2Look at the recap — three different outcomes from one playbook. Each host evaluated each condition against its own facts and group membership. That is what "conditionals are per-host" means concretely, and it is why a single playbook can serve a heterogeneous fleet.
skipping: is not a failure. A skipped task is a successful decision not to act. Interviewers sometimes probe this: skipped counts in the recap separately from failed precisely because skipping is normal, expected behaviour.
🧪 Exercise A1.2 — Break the operator-precedence rule
---
- name: Precedence trap
hosts: localhost
gather_facts: false
vars:
env: "dev"
is_prod: false
force_deploy: true
tasks:
- name: WITHOUT parentheses - this runs, and it should not
ansible.builtin.debug:
msg: "DEPLOYING - no parentheses"
when: env == "prod" or env == "staging" and force_deploy | bool
- name: WITH parentheses - correctly skipped
ansible.builtin.debug:
msg: "DEPLOYING - with parentheses"
when: (env == "prod" or env == "staging") and force_deploy | bool✅ Expected result — click to reveal
TASK [WITHOUT parentheses - this runs, and it should not] **********
skipping: [localhost]
TASK [WITH parentheses - correctly skipped] ************************
skipping: [localhost]Both skipped here — now change env to "staging" and run it again:
TASK [WITHOUT parentheses] *** ok: [localhost] => {"msg": "DEPLOYING - no parentheses"}
TASK [WITH parentheses] ****** ok: [localhost] => {"msg": "DEPLOYING - with parentheses"}Still the same. Now set force_deploy: false and env: "prod":
TASK [WITHOUT parentheses] *** ok: [localhost] <-- RUNS despite force_deploy being false
TASK [WITH parentheses] ****** skipping: [localhost] <-- correctly blockedThere it is. Without parentheses the expression parses as env == "prod" OR (env == "staging" AND force_deploy). Because env is prod, the first branch is true and the whole expression short-circuits to true — force_deploy is never even consulted.
Why this is genuinely dangerous: the safety flag you added to prevent accidental production deploys does nothing, and it does nothing only in production, which is the one case you were guarding. The bug is invisible in dev and staging testing. Parenthesise whenever you mix and with or.
🎯 Interview questions — Conditionals
Q. How do conditionals work in Ansible?
when: on a task, evaluated as a Jinja2 expression without {{ }} braces, per host. If it evaluates false the task is skipped for that host only, and skipped is reported separately from failed in the recap.
Conditions can be combined as a YAML list, which is an implicit AND and reads more clearly, or with explicit and / or / not.
Q. Why should you not write when: "{{ myvar }}"?
when: is already evaluated as a Jinja2 expression, so the braces are redundant. ansible-lint flags it, and on more complex expressions the double evaluation can break — particularly with nested quoting or filters.
The exception people cite is embedding a variable inside a string comparison, and even there the correct form is when: myvar == "value", not braces.
Q. How do you check whether a host belongs to a group inside a task?
when: "'web' in group_names" — group_names is a magic variable listing the groups the current host belongs to.
For the reverse direction, checking whether a named host is in a group, use when: "'web01' in groups['web']".
Note the outer quoting: the expression contains single quotes, so the whole thing is wrapped in double quotes to keep YAML happy.
Q. How do you compare version numbers correctly?
The version test: when: app_version is version('2.0', '>=').
String comparison is wrong because "10.0" < "9.0" lexically, and float comparison is wrong because 1.10 becomes 1.1. The version test understands semantic ordering and also supports strict=True for strict semver.
Q. What is the operator-precedence trap in when:?
and binds tighter than or, so a or b and c parses as a or (b and c).
The realistic failure: when: env == "prod" or env == "staging" and force_deploy — in production the first branch is true, the expression short-circuits, and the force_deploy safety flag is never evaluated. The guard silently does nothing, and only in the environment it was protecting.
Always parenthesise when mixing.
Part B · Loops
B1 · loop — the basics
loop is the first habit — and knowing when not to loop is the second. Some modules accept a whole list in one go, so looping over them means three trips to the shop where one would have done. That is why package: name: [nginx, git, htop] beats a loop of three package tasks, and why an interviewer asking "how would you install ten packages?" is often testing exactly this.
- name: Install several packages
ansible.builtin.package:
name: "{{ item }}"
state: present
loop:
- nginx
- git
- htopitem is the loop variable, provided automatically on each iteration.
- ansible.builtin.package:
name: [nginx, git, htop] # ONE transaction, one round trip
state: presentLooping runs the package manager three separate times — three SSH round trips, three transactions, roughly three times slower. Ansible will even warn you about it.
The general principle: check whether the module takes a list before reaching for loop. This is a favourite interview follow-up because it separates people who write playbooks from people who tune them.
Looping over a list of dictionaries
- name: Create application users
ansible.builtin.user:
name: "{{ item.name }}"
uid: "{{ item.uid }}"
groups: "{{ item.groups | default(omit) }}"
state: present
loop:
- { name: deploy, uid: 2001, groups: "docker" }
- { name: monitor, uid: 2002 }
- { name: backup, uid: 2003 }Looping over a dictionary — dict2items
You cannot loop a dictionary directly. Convert it first:
vars:
app_ports:
web: 8080
api: 9090
admin: 7070
tasks:
- name: Open each port
ansible.builtin.debug:
msg: "{{ item.key }} listens on {{ item.value }}"
loop: "{{ app_ports | dict2items }}"Useful loop sources
loop: "{{ groups['web'] }}" # every host in a group
loop: "{{ ansible_facts['mounts'] }}" # a list from facts
loop: "{{ range(1, 6) | list }}" # 1,2,3,4,5
loop: "{{ query('fileglob', '/etc/nginx/conf.d/*.conf') }}"
loop: "{{ list_a | zip(list_b) | list }}" # pair two lists
loop: "{{ list_a | product(list_b) | list }}" # every combination
loop: "{{ big_list | unique | sort }}"B2 · loop_control — making loops readable and usable
loop_control: label: is reading out only the name. Without it, Ansible prints the whole record for every item it loops over — which is both unreadable at thirty items and, if your list contains passwords or API keys, a genuine security problem in the job log.
- name: Create users
ansible.builtin.user:
name: "{{ item.name }}"
uid: "{{ item.uid }}"
loop: "{{ app_users }}"
loop_control:
label: "{{ item.name }}" # show only the name in output, not the whole dict
index_var: idx # idx = 0, 1, 2...
pause: 2 # seconds between iterations
loop_var: user # rename 'item' - REQUIRED for nested loopsloop_var: matters for nesting. An inner loop's item would shadow the outer one, so include_tasks with a loop inside a loop needs the outer renamed.
🧪 Exercise B2.1 — See what label actually saves you
---
- name: loop_control label
hosts: localhost
gather_facts: false
vars:
db_users:
- { name: app_rw, password: "S3cr3t-App", privs: "ALL" }
- { name: app_ro, password: "S3cr3t-RO", privs: "SELECT" }
tasks:
- name: WITHOUT label - look at the output carefully
ansible.builtin.debug:
msg: "Creating {{ item.name }}"
loop: "{{ db_users }}"
- name: WITH label
ansible.builtin.debug:
msg: "Creating {{ item.name }}"
loop: "{{ db_users }}"
loop_control:
label: "{{ item.name }}"✅ Expected result — click to reveal
TASK [WITHOUT label - look at the output carefully] ****************
ok: [localhost] => (item={'name': 'app_rw', 'password': 'S3cr3t-App', 'privs': 'ALL'}) => {
"msg": "Creating app_rw"
}
ok: [localhost] => (item={'name': 'app_ro', 'password': 'S3cr3t-RO', 'privs': 'SELECT'}) => {
"msg": "Creating app_ro"
}
TASK [WITH label] **************************************************
ok: [localhost] => (item=app_rw) => {
"msg": "Creating app_rw"
}
ok: [localhost] => (item=app_ro) => {
"msg": "Creating app_ro"
}The passwords are printed in plain text in the first task. Not in the msg — in the item echo that Ansible adds automatically. They are now in your terminal scrollback, in your CI job log, and in any log aggregation shipping that output.
loop_control: label: fixes it, and no_log: true on the task suppresses output entirely. For any loop over data containing secrets, you need at least one of them — and this is the concrete reason interviewers ask about no_log.
Readability is the secondary benefit and still a real one: a loop over 200 dictionaries is unusable output without a label.
B3 · until — retrying until something is true
That is until with retries and delay: the same task, tried again on a timer, until the condition comes true. And when the patience runs out the task fails, which is exactly what you want from a health check — a service that never came up should not be reported as fine.
- name: Wait for the application to become healthy
ansible.builtin.uri:
url: "http://{{ inventory_hostname }}:8080/health"
status_code: 200
register: health
until: health.status == 200
retries: 12
delay: 5 # 12 attempts, 5s apart = up to 60 seconds
changed_when: falseWhen it finally gives up, the task fails — which is usually what you want, because a health check that never passes should stop the deployment.
Related but different: the wait_for module blocks until a port or file condition is met, and wait_for_connection waits for the host itself to come back — which is what you use after a reboot.
B4 · register with a loop
A registered variable from a looped task is not a single result — it contains a results list, one entry per iteration.
- name: Check several services
ansible.builtin.command: "systemctl is-active {{ item }}"
loop: [nginx, sshd, cron]
register: svc
changed_when: false
failed_when: false
- name: Report the dead ones
ansible.builtin.debug:
msg: "{{ item.item }} is NOT running"
loop: "{{ svc.results }}"
loop_control:
label: "{{ item.item }}"
when: item.rc != 0B5 · with_* — the legacy syntax
with_items: "{{ mylist }}" # -> loop: "{{ mylist }}"
with_dict: "{{ mydict }}" # -> loop: "{{ mydict | dict2items }}"
with_fileglob: "/etc/*.conf" # -> loop: "{{ query('fileglob', '/etc/*.conf') }}"
with_nested: [ [a,b], [1,2] ] # -> loop: "{{ a | product(b) | list }}"
with_together: [ a, b ] # -> loop: "{{ a | zip(b) | list }}"You must be able to read with_* because it is everywhere in existing code, and you should write loop in new code.
🎯 Interview questions — Loops
Q. What is the difference between loop and with_items?
loop is the modern syntax, recommended since 2.5. with_* is the older family, still functional and everywhere in existing code.
The behavioural difference that matters: with_items flattens one level of nesting, loop does not. Migrating a list-of-lists from with_items to loop changes what the task does unless you add | flatten(1).
The with_* forms that were not simple lists — with_dict, with_fileglob, with_nested — become loop plus a filter or a query().
Q. Why is looping over a package list a bad idea?
Because the package modules accept a list natively. name: [nginx, git, htop] is one transaction and one round trip; looping is three of each, roughly three times slower, and Ansible warns about it.
The general rule: check whether the module already takes a list before reaching for loop. The same applies to user, file and several others in specific cases.
Q. You registered a looped task. What does the variable contain?
Not a single result — an object with a results list, one entry per iteration, each being a full result object. Each entry also carries .item, the original loop value that produced it.
So iterating the results reads as loop: "{{ svc.results }}" with item.item for the original value and item.rc for that iteration's exit code.
Q. What is loop_control and why does label matter?
It configures loop behaviour: label controls what is printed per iteration, index_var exposes the counter, pause inserts a delay, and loop_var renames item — which is required for nested loops, since an inner loop would otherwise shadow the outer one.
label matters for two reasons: without it, a loop over dictionaries prints the entire dictionary, including any password in it, into your terminal and CI logs. And with hundreds of items the output is unreadable. For secrets, combine it with no_log: true.
Q. How do you retry a task until a condition is met?
until: with retries: and delay: — it re-runs the same task until the condition is true or the retries are exhausted, at which point the task fails.
Defaults are 3 retries and 5 seconds. It pairs with register because the condition normally tests the registered result.
Related modules worth naming: wait_for for a port or file condition, and wait_for_connection for waiting on a host to come back after a reboot.
Part C · Error handling and recovery
C1 · Controlling failure and change
ignore_errors: true is taking the battery out. failed_when: is adjusting the sensitivity so it ignores toast but still screams at smoke. One removes the warning entirely; the other makes it accurate. If you take one habit from this section, make it reaching for failed_when: first and ignore_errors: only when you genuinely do not care about the outcome.
By default a failed task stops the play for that host. Other hosts continue. Four keywords change that.
| Keyword | Effect |
|---|---|
| ignore_errors: true | Task can fail; the play continues. The task is still reported as failed |
| failed_when: | Redefine what failure means for this task |
| changed_when: | Redefine what "changed" means — from Module 01 |
| ignore_unreachable: true | Continue even if the host is unreachable, which ignore_errors does not cover |
# ignore_errors - blunt instrument, use sparingly
- name: Try to stop a service that may not exist
ansible.builtin.service:
name: legacy-app
state: stopped
ignore_errors: true
# failed_when - precise, and almost always better
- name: Run a health check
ansible.builtin.command: /usr/local/bin/healthcheck
register: health
changed_when: false
failed_when: health.rc not in [0, 2] # rc=2 means "degraded", acceptable here
# failed_when: false - "never fail" - clearer intent than ignore_errors
- name: Probe an optional endpoint
ansible.builtin.uri:
url: http://localhost:9000/metrics
register: metrics
failed_when: falsePrefer failed_when: with a real condition. Reach for ignore_errors only when you genuinely cannot predict the failure modes.
And note: neither covers an unreachable host — that needs ignore_unreachable: true.
fail and assert — failing on purpose
- name: Refuse to run against production without approval
ansible.builtin.fail:
msg: "Deploying to prod requires -e approved=true"
when: env == "prod" and not (approved | default(false) | bool)
- name: Validate inputs before doing anything destructive
ansible.builtin.assert:
that:
- app_version is defined
- app_version is version('2.0', '>=')
- target_dir is defined
fail_msg: "app_version must be >= 2.0 and target_dir must be set"
success_msg: "Inputs validated"Put an assert block at the top of any playbook that takes parameters. Failing in two seconds with a clear message beats failing four minutes in with a half-written config file.
C2 · block / rescue / always
block is the procedure, rescue is the emergency response, and always is closing up. That last one is why re-enabling a server in the load balancer belongs in always and nowhere else. And here is the trap worth remembering: if the rescue only writes "recovered" in the notes without actually fixing anything, the paperwork says success while the patient is not fine. A rescue that cannot genuinely recover must still end by reporting failure.
Ansible's try/catch/finally.
Diagram source
flowchart TD
A["block:<br>the tasks you want to run"] --> B{"Did any task fail?"}
B -->|"No"| C["rescue: is SKIPPED"]
B -->|"Yes"| D["Stop the block immediately<br>run rescue:"]
D --> E{"Did rescue succeed?"}
E -->|"Yes"| F["Host is marked OK again<br>play continues"]
E -->|"No"| G["Host fails for real"]
C --> H["always: runs"]
F --> H
G --> H
H --> I["always: ALWAYS runs<br>success, failure, or rescue"]
style D fill:#FEE2E2,stroke:#DC2626
style F fill:#D1FAE5,stroke:#059669,stroke-width:2px
style I fill:#FEF3C7,stroke:#D97706,stroke-width:2px- name: Deploy with rollback
block:
- name: Take the node out of the load balancer
ansible.builtin.uri:
url: "http://lb.internal/drain/{{ inventory_hostname }}"
method: POST
- name: Deploy the new release
ansible.builtin.unarchive:
src: "app-{{ app_version }}.tar.gz"
dest: /opt/app/current
remote_src: false
- name: Verify it is healthy
ansible.builtin.uri:
url: "http://{{ inventory_hostname }}:8080/health"
status_code: 200
retries: 6
delay: 5
rescue:
- name: Roll back to the previous release
ansible.builtin.command: /opt/app/rollback.sh
- name: Alert the team
ansible.builtin.debug:
msg: "Deployment failed on {{ inventory_hostname }} and was rolled back"
always:
- name: Put the node back in the load balancer whatever happened
ansible.builtin.uri:
url: "http://lb.internal/enable/{{ inventory_hostname }}"
method: POSTThat is exactly right for a genuine recovery — you rolled back, the system is consistent, carry on. It is exactly wrong if your rescue only logs the error, because you have now silently swallowed a failure and CI reports green.
If the rescue does not truly fix the problem, end it with a fail task to re-raise.
The variables rescue gives you
rescue:
- ansible.builtin.debug:
msg: |
Failed task: {{ ansible_failed_task.name }}
Error: {{ ansible_failed_result.msg | default('no message') }}🧪 Exercise C2.1 — Prove that a successful rescue clears the failure
---
- name: block / rescue / always
hosts: localhost
gather_facts: false
tasks:
- name: Deployment with recovery
block:
- name: This will fail
ansible.builtin.command: /bin/false
- name: Never reached
ansible.builtin.debug:
msg: "you will not see this"
rescue:
- name: Recovery ran
ansible.builtin.debug:
msg: "Rescued. Failed task was: {{ ansible_failed_task.name }}"
always:
- name: Cleanup always runs
ansible.builtin.debug:
msg: "always block"
- name: The play continues normally
ansible.builtin.debug:
msg: "still running after the failure"✅ Expected result — click to reveal
TASK [This will fail] **********************************************
fatal: [localhost]: FAILED! => {"changed": true, "cmd": "/bin/false", "rc": 1}
TASK [Recovery ran] ************************************************
ok: [localhost] => {
"msg": "Rescued. Failed task was: This will fail"
}
TASK [Cleanup always runs] *****************************************
ok: [localhost] => {"msg": "always block"}
TASK [The play continues normally] *********************************
ok: [localhost] => {"msg": "still running after the failure"}
PLAY RECAP *********************************************************
localhost : ok=3 changed=1 unreachable=0 failed=0 rescued=1 ignored=0Read the recap: failed=0 and rescued=1. Despite a red fatal: line in the output, the play succeeded and would exit zero in CI.
Note also that "Never reached" did not run. A block aborts at the first failure — the remaining block tasks are skipped entirely, which is what makes rollback logic safe to write.
The trap: if your rescue only logs, you have converted a real failure into a green build. Anyone reading the exit code sees success. If the rescue does not genuinely restore a good state, end it with:
- name: Re-raise after logging
ansible.builtin.fail:
msg: "Deployment failed and could not be recovered"C3 · Failure across the whole fleet
| Keyword | Effect |
|---|---|
| (default) | A failed host drops out; other hosts continue the play |
| any_errors_fatal: true | One host failing aborts the play for all hosts, at the end of the current task |
| max_fail_percentage: 30 | Abort once more than 30% of hosts in the current batch have failed |
| force_handlers: true | Run queued handlers even though the play failed |
🎯 Interview questions — Error handling
Q. Explain block, rescue and always.
Ansible's try/catch/finally. block groups tasks; if any fails, the block aborts immediately and rescue runs; always runs regardless of outcome.
The critical detail: if rescue completes successfully the host is no longer marked failed — the recap shows rescued=1, failed=0 and the play continues.
That is correct for a genuine recovery and dangerous if the rescue only logs, because you have turned a real failure into a green CI build. If the rescue does not restore a good state, end it with fail.
ansible_failed_task and ansible_failed_result are available inside rescue for reporting.
Q. ignore_errors vs failed_when: false — which and why?
Both let the play continue. ignore_errors still reports the task as failed with a red fatal: ...ignoring line, which trains people to overlook red text in CI. failed_when: false declares the task cannot fail and reports ok.
Prefer a real failed_when: condition — failed_when: result.rc not in [0, 2] — because it encodes which outcomes are actually acceptable rather than blanket-suppressing everything.
And neither handles an unreachable host; that needs ignore_unreachable: true.
Q. One host fails mid-play. What happens to the others?
By default the failed host is removed from the play and every other host continues. The failure is reported in the recap and the run exits non-zero.
Change it with any_errors_fatal: true to abort everywhere on the first failure, or max_fail_percentage: N to abort once a threshold of the current batch has failed — which only has meaning together with serial.
Q. How would you make a deployment roll back automatically on failure?
block containing drain, deploy and health check; rescue performing the rollback and alerting; always returning the node to the load balancer whatever happened.
Combine with serial so only a batch is affected, and max_fail_percentage: 0 so the rollout halts rather than continuing through the fleet.
And a caveat worth volunteering: if the rescue cannot genuinely restore a good state, it must end with fail, otherwise the pipeline reports success on a broken deployment.
Q. When do you use assert rather than fail?
assert for input validation at the start of a playbook — it states positively what must be true and reports which specific condition failed.
fail for a deliberate stop based on a more complex or procedural condition, where you want to control the message precisely.
The practical argument: failing in two seconds with a clear message beats failing four minutes in with a half-written config on disk.
Part D · Controlling what runs — tags, imports and includes
D1 · Tags
The tabs are tags:, and "wash your hands" is tags: always. Skip those universal steps and the dessert fails for reasons that look nothing like a tagging problem — which is exactly what happens when you run --tags deploy and your include_vars task, untagged, never runs. That is the single most common tagging bug in real playbooks.
Tags let you run part of a playbook without editing it.
- name: Install packages
ansible.builtin.package:
name: nginx
state: present
tags: [install, packages]
- name: Deploy configuration
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
tags: [config, deploy]
- name: Run the full test suite
ansible.builtin.command: /opt/tests/run.sh
tags: [test, slow]ansible-playbook site.yml --tags config # only config-tagged tasks
ansible-playbook site.yml --tags "config,deploy" # either tag
ansible-playbook site.yml --skip-tags slow # everything except slow
ansible-playbook site.yml --list-tags # what tags exist?The four special tags
| Tag | Meaning |
|---|---|
| always | Runs every time, regardless of --tags — unless explicitly skipped with --skip-tags always |
| never | Never runs unless the tag is requested explicitly. For destructive or very slow tasks |
| tagged / untagged | Select all tasks that do, or do not, carry any tag |
| all | Everything — the default |
- name: Load variables - needed by everything
ansible.builtin.include_vars: vars/main.yml
tags: always # runs even with --tags config
- name: Wipe and rebuild the database
ansible.builtin.command: /opt/db/rebuild.sh
tags: [never, rebuild] # only runs with --tags rebuildAnything that sets up state other tasks depend on — variable loading, fact gathering, assertions — needs tags: always. This is the single most useful thing to know about tags, and it is very commonly asked.
D2 · import_* vs include_* — static vs dynamic
The photocopy is import_*; the note saying "see page 40" is include_*. Decided now versus decided later — and every other difference between them follows from that one sentence, including why you cannot --tags your way into an included file and why a loop only works with include_tasks.
This is the most conceptually important topic in Module 03, and one of the most reliably asked.
Diagram source
flowchart TD
A["ansible-playbook site.yml"] --> B["PARSE TIME<br>the whole playbook is read"]
B --> C["import_tasks / import_role<br>import_playbook"]
C --> D["Contents are inserted INLINE<br>as if you had typed them there"]
D --> E["Visible to --list-tasks<br>Taggable individually<br>CANNOT use a variable in the filename<br>CANNOT be looped"]
B --> F["RUN TIME<br>tasks execute one by one"]
F --> G["include_tasks / include_role<br>include_vars"]
G --> H["Contents are loaded WHEN REACHED"]
H --> I["Invisible to --list-tasks<br>Tags apply to the include as a whole<br>CAN use a variable in the filename<br>CAN be looped"]
style D fill:#DDD6FE,stroke:#7C3AED,stroke-width:2px
style H fill:#FEF3C7,stroke:#D97706,stroke-width:2px| import_* — STATIC | include_* — DYNAMIC | |
|---|---|---|
| Processed | At parse time, before anything runs | At run time, when reached |
| Variable in the filename | ❌ Only variables known at parse time | ✅ Any variable, including facts |
| Can be looped | ❌ No | ✅ Yes |
| --list-tasks | ✅ Shows every inner task | ❌ Shows only the include line |
| --start-at-task on inner tasks | ✅ Works | ❌ Does not |
| Tag behaviour | Tags are inherited by every inner task | Tags apply to the include as a whole |
| when: behaviour | Copied onto each inner task | Applied once to the include itself |
# STATIC - fixed structure, fully visible to tooling
- ansible.builtin.import_tasks: setup.yml
- ansible.builtin.import_role:
name: common
# DYNAMIC - required when the target depends on runtime data
- ansible.builtin.include_tasks: "{{ ansible_facts['os_family'] }}.yml"
- ansible.builtin.include_tasks: configure-app.yml
loop: "{{ applications }}"
loop_control:
loop_var: appThe performance note worth adding: importing everything expands the whole playbook at parse time, which on a very large role tree makes startup slow and memory-hungry. Dynamic includes stay cheap until reached. So "import by default" is a readability default, not an absolute.
On an include_tasks, the when: is evaluated once, against the state at that moment, and either the whole file runs or none of it does.
Same YAML, different semantics. This produces bugs that look impossible until you know the rule.
🧪 Exercise D2.1 — Show that a variable filename only works with include
---
- name: import vs include
hosts: localhost
gather_facts: true
tasks:
- name: This works - resolved at run time
ansible.builtin.include_tasks: "os-{{ ansible_facts['os_family'] }}.yml"
- name: This fails - the filename is needed at parse time
ansible.builtin.import_tasks: "os-{{ ansible_facts['os_family'] }}.yml"printf -- "- debug:\n msg: Debian tasks ran\n" > os-Debian.yml
printf -- "- debug:\n msg: RedHat tasks ran\n" > os-RedHat.yml
ansible-playbook importvsinclude.yml✅ Expected result — click to reveal
TASK [This works - resolved at run time] ***************************
included: /home/zaeem/lab/os-Debian.yml for localhost
TASK [debug] *******************************************************
ok: [localhost] => {"msg": "Debian tasks ran"}
ERROR! Unable to retrieve file contents
Could not find or access 'os-{{ ansible_facts[\'os_family\'] }}.yml'The error is the lesson. import_tasks is processed before any host is contacted, so ansible_facts does not exist yet — the filename is still the literal, untemplated string. The include succeeded because by the time it was reached, facts had been gathered.
Now run ansible-playbook importvsinclude.yml --list-tasks with only the include, and note that the inner debug task does not appear. The tooling cannot see inside a dynamic include, which is exactly the trade-off: runtime flexibility costs you static visibility.
The rule that follows: if a filename or a loop depends on runtime data, you have no choice — use include_*. Otherwise prefer import_* and keep your playbook introspectable.
D3 · pre_tasks, roles, tasks, post_tasks
A play runs its sections in a fixed order, regardless of how you order them in the file:
- name: Ordered play
hosts: web
pre_tasks:
- name: Runs FIRST
ansible.builtin.debug: {msg: "1 - pre_tasks"}
roles:
- common # 2 - roles
tasks:
- name: Runs THIRD
ansible.builtin.debug: {msg: "3 - tasks"}
post_tasks:
- name: Runs LAST
ansible.builtin.debug: {msg: "4 - post_tasks"}This ordering is fixed. Writing tasks: above pre_tasks: in the file changes nothing.
🎯 Interview questions — Tags, imports and includes
Q. What is the difference between import_tasks and include_tasks?
import_tasks is static, processed at parse time — the contents are inserted inline as though you had typed them. include_tasks is dynamic, processed at run time when the task is reached.
The consequences: only include can take a variable filename or be looped, because those depend on runtime data. Only import is visible to --list-tasks, usable with --start-at-task, and fully analysable by ansible-lint.
Tags and when: also behave differently: on an import they are applied to every inner task; on an include they apply to the include as a single unit.
Q. Which should you prefer, and when do you break that rule?
Prefer import_* by default — static structure keeps the playbook introspectable by tooling and by humans.
Break it when you need runtime behaviour: a filename built from a fact, a loop over includes, or a conditional that must be evaluated against gathered data.
The performance caveat worth adding: importing a very large role tree expands everything at parse time, which slows startup and uses memory. Dynamic includes stay cheap until reached, so at scale it becomes a genuine trade-off rather than a style preference.
Q. What are the special tags?
always — runs regardless of --tags, unless explicitly skipped. never — runs only when requested by name, for destructive or very slow tasks. tagged and untagged — select tasks that do or do not carry any tag. all — the default.
The practical point: anything that sets up state other tasks depend on, such as include_vars, must be tagged always, otherwise --tags config skips it and you get confusing undefined-variable errors that look like a variable bug.
Q. What order do pre_tasks, roles, tasks and post_tasks run in?
Always that order, regardless of how they are arranged in the file — and handlers are flushed between each section.
That is why pre_tasks is the natural home for draining a node from a load balancer or applying config that must take effect before the roles run, and post_tasks for verification and re-enabling.
Part E · Orchestration — delegation and rolling updates
E1 · delegate_to, run_once and local_action
That is delegate_to: the task runs somewhere else, while everything about it still refers to the server you are currently working on. It is exactly how one server tells the load balancer "stop sending traffic to me" — the API call is made from the control node, but inventory_hostname still means the machine being drained. Without that, every host in the play would have to know how to talk to the load balancer itself.
delegate_to runs a task on a different host than the one currently being processed, while keeping that host's variables in scope.
- name: Remove this node from the load balancer
ansible.builtin.uri:
url: "http://lb.internal/disable/{{ inventory_hostname }}"
method: POST
delegate_to: lb01 # runs ON lb01, about the current host
- name: Wait for the node to come back after reboot
ansible.builtin.wait_for:
host: "{{ inventory_hostname }}"
port: 22
delay: 10
timeout: 300
delegate_to: localhost # the control node does the waiting
- name: Run the database migration exactly once
ansible.builtin.command: /opt/app/migrate.sh
run_once: true # first host of the batch only
delegate_to: "{{ groups['db'][0] }}"delegate_facts: true additionally assigns any gathered facts to the delegated host instead of the current one.
| Form | Meaning |
|---|---|
| delegate_to: localhost | Run on the control node — API calls, waiting, local file work |
| connection: local | Same effect for a whole play, without changing the host context |
| local_action: | Older shorthand for delegate_to: localhost. Read it, do not write it |
| run_once: true | Execute on the first host of the batch only, but apply the result to all |
E2 · serial — rolling updates
That is serial: [1, 5, "25%"] — one room, then a few, then a wing at a time. The first batch is your canary: if the colour is wrong you have one room to repaint, not two hundred. This is also the coach tour from Module 01 D4 finally being allowed to leave in groups rather than all together.
- name: Rolling deployment
hosts: web
serial: 2 # two hosts at a time through the WHOLE play
max_fail_percentage: 0 # any failure halts the rolloutserial: 1 # one at a time - safest, slowest
serial: "25%" # a quarter of the fleet per batch
serial: [1, 5, 10] # canary of 1, then 5, then 10, then 10...
serial: [1, "10%", "50%"] # mixedOne consequence people miss: ansible_play_hosts inside a serial play contains only the current batch, not the whole group. If you need the full list, that is ansible_play_hosts_all.
🧪 Exercise E2.1 — A complete rolling deployment
---
- name: Zero-downtime rolling deployment
hosts: web
serial: [1, 2] # canary of one, then two at a time
max_fail_percentage: 0
become: true
tasks:
- name: Drain from the load balancer
ansible.builtin.uri:
url: "http://lb.internal/disable/{{ inventory_hostname }}"
method: POST
status_code: [200, 204]
delegate_to: localhost
changed_when: false
- name: Wait for connections to finish
ansible.builtin.pause:
seconds: 10
- name: Deploy with automatic rollback
block:
- name: Deploy the new version
ansible.builtin.copy:
content: "version={{ app_version }}\n"
dest: /opt/app/VERSION
mode: "0644"
notify: Restart app
- name: Apply the restart now, before verifying
ansible.builtin.meta: flush_handlers
- name: Verify health
ansible.builtin.uri:
url: "http://{{ inventory_hostname }}:8080/health"
status_code: 200
register: health
until: health.status == 200
retries: 6
delay: 5
changed_when: false
rescue:
- name: Roll back
ansible.builtin.command: /opt/app/rollback.sh
- name: Re-raise so the rollout halts
ansible.builtin.fail:
msg: "Deploy failed on {{ inventory_hostname }}; rolled back and stopping"
always:
- name: Return to the load balancer
ansible.builtin.uri:
url: "http://lb.internal/enable/{{ inventory_hostname }}"
method: POST
status_code: [200, 204]
delegate_to: localhost
changed_when: false
handlers:
- name: Restart app
ansible.builtin.service:
name: myapp
state: restarted✅ Expected result — and every design decision in it — click to reveal
PLAY [Zero-downtime rolling deployment] ****************************
TASK [Drain from the load balancer] *** ok: [web01] <-- batch 1: ONE host
TASK [Wait for connections to finish] * ok: [web01]
TASK [Deploy the new version] ********* changed: [web01]
RUNNING HANDLER [Restart app] ********* changed: [web01]
TASK [Verify health] ****************** ok: [web01]
TASK [Return to the load balancer] **** ok: [web01]
PLAY [Zero-downtime rolling deployment] ****************************
TASK [Drain from the load balancer] *** ok: [web02] <-- batch 2: TWO hosts
ok: [web03]
...
PLAY RECAP *********************************************************
web01 : ok=6 changed=2 failed=0
web02 : ok=6 changed=2 failed=0
web03 : ok=6 changed=2 failed=0Notice the play header repeats. Each serial batch is a fresh pass through the entire play — that is the mechanism, made visible.
Seven deliberate decisions, each worth being able to justify:
- serial: [1, 2] — canary first. A bad release breaks one host, not the fleet.
- max_fail_percentage: 0 — the rollout halts rather than continuing.
- delegate_to: localhost on the LB calls — the load balancer API is reached from the control node, but inventory_hostname still names the host being drained.
- meta: flush_handlers before the health check — without it the restart happens at the end of the play and you would verify the old version. Module 01 Part D3.
- until / retries on the health check — services take time to come up; a single immediate check would fail spuriously.
- always for re-enabling — a node must never be left drained, no matter what failed.
- fail at the end of rescue — without it, a successful rescue clears the failure, max_fail_percentage never triggers, and the rollout continues happily through a broken deployment.
Point 7 is the one that separates candidates. It is the interaction between two features — rescue clearing failure state, and max_fail_percentage depending on failure state — and you only find it by having been burned.
🎯 Interview questions — Orchestration
Q. What is delegate_to and when do you use it?
It runs a task on a different host while keeping the current host's variables in scope — so inventory_hostname still refers to the host being processed.
Typical uses: calling a load balancer API from localhost to drain the current node; adding a DNS record on a management host; running a database migration on a single DB node.
delegate_facts: true assigns gathered facts to the delegated host instead of the current one. local_action is the older shorthand for delegate_to: localhost.
Q. Design a zero-downtime rolling deployment across 500 servers.
serial: [1, 10, "25%"] for a canary then progressive batches, with max_fail_percentage: 0 so the rollout halts on the first failure.
Per batch: drain from the load balancer with delegate_to: localhost, pause for connection draining, deploy inside a block, meta: flush_handlers to apply the restart, health check with until/retries, and always to re-enable.
rescue performs the rollback and must end with fail, otherwise the recovered state clears the failure, max_fail_percentage never fires, and the rollout continues through a broken release.
Before any of it: --check --diff --limit one-host.
Q. What is run_once and what is it commonly paired with?
It executes a task on the first host of the batch only, while applying the result to the whole play — used for singular operations such as a schema migration, creating a shared resource, or sending one deployment notification.
Commonly paired with delegate_to to control which host it runs on, since "the first host of the batch" is otherwise arbitrary.
Caveat: with serial, run_once fires once per batch, not once per play — which surprises people writing migrations.
Q. Inside a serial play, what does ansible_play_hosts contain?
Only the hosts in the current batch, not the whole group. For the complete list use ansible_play_hosts_all.
It matters when building a config that must list every peer — an nginx upstream block or a cluster member list — because with serial you would otherwise generate a config naming only the two hosts in the current batch.
Part F · Putting it together
F1 · Production practice
| Habit | Why |
|---|---|
| Parenthesise whenever and and or appear together | and binds tighter — your safety flag silently stops being evaluated, and only in production |
| Never write {{ }} inside when: | Already a Jinja2 expression; redundant, linted against, and breaks on complex expressions |
| Check whether a module takes a list before using loop | Looping package is three round trips instead of one |
| loop_control: label: on every loop over dictionaries | Otherwise the full dict — including passwords — is printed to logs |
| no_log: true on any task handling secrets | Suppresses both arguments and output from logs |
| failed_when: with a real condition, not blanket ignore_errors | Encodes which outcomes are acceptable instead of hiding all of them |
| End a rescue with fail unless it truly restored good state | A successful rescue clears the failure and turns a broken deploy into a green build |
| assert block at the top of any parameterised playbook | Fail in two seconds with a clear message, not four minutes in with a half-written config |
| tags: always on include_vars and other setup tasks | Otherwise --tags config skips them and you debug a phantom variable bug |
| import_* by default, include_* only when you need runtime behaviour | Keeps --list-tasks, --start-at-task and ansible-lint working |
| serial • max_fail_percentage: 0 for any fleet-wide change | Halts at the first failure instead of propagating a bad release to 500 hosts |
| always: blocks for anything that must be undone | A node left drained from the load balancer is an outage you caused |
F2 · Capstone exercise
Brief. Write a playbook that patches the web group:
- Runs one host at a time, halting entirely on the first failure
- Refuses to run at all unless -e approved=true is supplied
- Loads variables from a file that still loads when run with --tags patch
- Installs a list of packages in one transaction, not a loop
- Drains the host from the load balancer before patching and always re-enables it
- Reboots only if a reboot is actually required, then waits for the host to return
- Rolls back and halts the rollout if the post-patch health check fails
- Tags the slow full-test-suite task so it runs only when explicitly requested
✅ Model answer — attempt it first, then click
---
- name: Rolling patch with rollback
hosts: web
serial: 1 # requirement 1
max_fail_percentage: 0 # requirement 1
become: true
pre_tasks:
- name: Requirement 2 - refuse to run without approval
ansible.builtin.assert:
that:
- approved | default(false) | bool
fail_msg: "Refusing to patch. Re-run with -e approved=true"
tags: always # must run even with --tags patch
- name: Requirement 3 - load vars, always
ansible.builtin.include_vars: vars/patching.yml
tags: always
tasks:
- name: Requirement 5 - drain from the load balancer
ansible.builtin.uri:
url: "http://lb.internal/disable/{{ inventory_hostname }}"
method: POST
status_code: [200, 204]
delegate_to: localhost
changed_when: false
tags: patch
- name: Patch with rollback
block:
- name: Requirement 4 - ONE transaction, not a loop
ansible.builtin.package:
name: "{{ patch_packages }}" # a LIST variable
state: latest
- name: Requirement 6 - is a reboot needed?
ansible.builtin.stat:
path: /var/run/reboot-required
register: reboot_flag
- name: Requirement 6 - reboot only if required
ansible.builtin.reboot:
reboot_timeout: 600
when: reboot_flag.stat.exists
- name: Requirement 7 - health check
ansible.builtin.uri:
url: "http://{{ inventory_hostname }}:8080/health"
status_code: 200
register: health
until: health.status == 200
retries: 12
delay: 5
changed_when: false
rescue:
- name: Roll back the packages
ansible.builtin.command: /opt/scripts/rollback-packages.sh
- name: Requirement 7 - re-raise so the rollout HALTS
ansible.builtin.fail:
msg: "Patching failed on {{ inventory_hostname }}; rolled back and stopping"
always:
- name: Requirement 5 - always re-enable
ansible.builtin.uri:
url: "http://lb.internal/enable/{{ inventory_hostname }}"
method: POST
status_code: [200, 204]
delegate_to: localhost
changed_when: false
tags: patch
post_tasks:
- name: Requirement 8 - slow suite, opt-in only
ansible.builtin.command: /opt/tests/full-suite.sh
changed_when: false
tags: [never, fulltest]The six things most people miss:
- tags: always on the assert and include_vars. Run with --tags patch and without them, the approval gate is skipped entirely and patch_packages is undefined.
- name: "{{ patch_packages }}" with a list variable — one transaction. A loop here would be three or ten round trips and Ansible would warn about it.
- fail at the end of rescue. Without it the rescue clears the failure, max_fail_percentage: 0 never fires, and the patch rolls on to every remaining host.
- always: on the block re-enables the node even when the rescue itself failed. A host left drained is an outage you created while trying to be careful.
- stat before reboot — requirement 6 said "only if required". Rebooting unconditionally is both slower and a needless availability risk.
- tags: [never, fulltest] — never is what makes it opt-in. A plain fulltest tag would still run in a normal untagged execution.
Verify:
ansible-playbook patch.yml # should FAIL on the assert
ansible-playbook patch.yml -e approved=true --check --diff --limit web01
ansible-playbook patch.yml -e approved=true --list-tasks
ansible-playbook patch.yml -e approved=true --tags patch
ansible-playbook patch.yml -e approved=true --tags fulltestF3 · Command reference — everything from this module
Tag-driven runs
ansible-playbook site.yml --list-tags # ⭐ what tags exist?
ansible-playbook site.yml --tags config # ⭐ run one slice
ansible-playbook site.yml --tags "config,deploy" # either tag
ansible-playbook site.yml --skip-tags slow # ⭐ everything except
ansible-playbook site.yml --tags never,rebuild # opt in to a 'never' taskPartial and targeted runs
ansible-playbook site.yml --list-tasks # ⭐ full ordered scope
ansible-playbook site.yml --limit web01 # ⭐ one host first, always
ansible-playbook site.yml --limit @site.retry # ⭐ only the hosts that failed
ansible-playbook site.yml --start-at-task "Deploy" # resume after a failure
ansible-playbook site.yml --step # confirm each task
ansible-playbook site.yml --check --diff # ⭐ dry run with the diff
ansible-playbook site.yml --force-handlers # run handlers despite a failure
ansible-playbook site.yml --flush-cache # discard cached factsControl-flow keywords — the quick reference
# CONDITIONALS
when: ansible_facts['os_family'] == "RedHat" # ⭐ no {{ }} braces
when: # ⭐ a list is an implicit AND
- a == 1
- b | bool
when: (a or b) and c # ⭐ parenthesise when mixing
when: myvar is defined # ⭐
when: app_version is version('2.0', '>=') # ⭐ correct version comparison
when: "'web' in group_names" # ⭐ group membership
# LOOPS
loop: "{{ mylist }}" # ⭐
loop: "{{ mydict | dict2items }}" # ⭐ dictionaries need converting
loop: "{{ groups['web'] }}"
loop_control:
label: "{{ item.name }}" # ⭐ hides secrets, shortens output
loop_var: outer # required for nesting
index_var: idx
until: result.status == 200 # ⭐ retry loop
retries: 12
delay: 5
# ERROR HANDLING
ignore_errors: true # blunt - prefer failed_when
ignore_unreachable: true # ignore_errors does NOT cover this
failed_when: result.rc not in [0, 2] # ⭐ precise
changed_when: false # ⭐ read-only tasks never "change"
no_log: true # ⭐ suppress secrets from logs
any_errors_fatal: true
max_fail_percentage: 0 # ⭐ only meaningful with serial
block: / rescue: / always: # ⭐ try / catch / finally
# STRUCTURE
import_tasks: setup.yml # ⭐ static, parse time
include_tasks: "{{ os_family }}.yml" # ⭐ dynamic, run time
tags: always # ⭐ on include_vars and asserts
tags: [never, rebuild] # opt-in only
# ORCHESTRATION
delegate_to: localhost # ⭐ run here, about the remote host
delegate_facts: true
run_once: true # ⭐ once per BATCH, not per play
serial: [1, 5, "25%"] # ⭐ the canary pattern
meta: flush_handlers # ⭐ apply restarts before verifyingserial: [1, 5, "25%"]
max_fail_percentage: 0plus a block / rescue / always where the rescue ends in fail. Those four elements together are what "zero-downtime deployment with automatic rollback" actually means in Ansible, and being able to write them from memory answers most orchestration questions on its own.
F4 · Official documentation
| Link | Covers |
|---|---|
| Conditionals | when:, operators, conditions on roles and includes |
| Tests | is defined, is version(), is succeeded and the rest |
| Loops | loop, loop_control, until, and migrating from with_* |
| Error handling in playbooks | block/rescue/always, failed_when, any_errors_fatal |
| Tags | Tag inheritance and the special tags |
| Re-using files: includes vs imports | The static/dynamic distinction in full |
| Delegation and local actions | delegate_to, run_once, delegate_facts |
| Controlling execution: strategies | serial, strategy, throttle, forks |
| Playbook keywords | Which of these keywords is legal at play, block or task level |
F5 · Self-assessment
1. Why must you never write when: "{{ myvar }}"?
when: is already evaluated as a Jinja2 expression, so the braces are redundant, ansible-lint flags them, and on complex expressions the double evaluation can break — particularly with nested quoting or filters.
2. What is the operator-precedence trap, and why is it dangerous?
and binds tighter than or, so a or b and c parses as a or (b and c).
The realistic failure: when: env == "prod" or env == "staging" and force_deploy — in production the first branch is true, the expression short-circuits, and the safety flag is never evaluated. The guard silently does nothing, only in the environment it was protecting.
3. Why should you not loop over a package list?
The package modules take a list natively — one transaction, one round trip. Looping runs the package manager once per item, which is several times slower, and Ansible warns about it.
The general rule: check whether the module already accepts a list before reaching for loop.
4. What does loop_control: label: protect you from?
Without it, a loop over a list of dictionaries prints the entire dictionary on every iteration — including any password or token — into your terminal, CI logs and log aggregation.
It also makes a 200-item loop readable. For genuine secrets, combine with no_log: true.
5. A registered variable from a looped task — what is in it?
A results list, one full result object per iteration. Each entry carries .item, the original loop value that produced it — hence the item.item idiom when iterating over results.
6. What happens to the host after a successful rescue?
It is no longer marked failed — the recap shows rescued=1, failed=0 and the play continues.
Correct for a genuine recovery, dangerous if the rescue only logs, because a real failure becomes a green build. If the rescue does not restore good state, end it with fail.
7. ignore_errors vs failed_when: false?
Both continue the play. ignore_errors still reports a red fatal: ...ignoring, training people to overlook red text in CI. failed_when: false reports ok.
Prefer a real condition — failed_when: rc not in [0, 2] — which encodes which outcomes are acceptable. Neither covers an unreachable host; that needs ignore_unreachable.
8. Name three things import_tasks can do that include_tasks cannot, and vice versa.
Import only: visible to --list-tasks; usable with --start-at-task; tags and when: are applied to every inner task individually.
Include only: a variable in the filename; being looped; a when: evaluated once against runtime state.
Import is parse time, include is run time — every difference follows from that.
9. Why does include_vars need tags: always?
Because running with --tags config skips every untagged task, including the variable load — so the config template then fails on undefined variables, and the error looks like a variable bug rather than a tagging bug.
Anything that sets up state other tasks depend on needs tags: always.
10. What does delegate_to change, and what does it deliberately not change?
It changes where the task executes. It deliberately does not change the host context — inventory_hostname and all the current host's variables still refer to the original host.
That is what makes "tell the load balancer to drain this node" a single line.
11. Write the four ingredients of a safe fleet-wide rollout.
serial: [1, 5, "25%"] for a canary then progressive batches. max_fail_percentage: 0 to halt on failure. block/rescue/always for rollback and guaranteed re-enable. And a fail at the end of the rescue so the failure state survives for max_fail_percentage to act on.
Preceded by --check --diff --limit one-host.
12. Inside a serial play, what is in ansible_play_hosts?
Only the current batch. For every host in the play use ansible_play_hosts_all.
It matters when templating a config that must list all peers — an nginx upstream block or a cluster member list — because otherwise you generate a config naming only the current batch.
You have used filters throughout this module — default, bool, dict2items, version. Module 04 covers the templating engine properly: expressions, tests, whitespace control, template vs copy, and building config files that stay readable.
📚 Sources for the interview questions
Behaviour verified against the current official Ansible documentation.
Question selection cross-referenced against publicly published 2026 Ansible interview question sets:
- Spacelift — 50+ Top Ansible Interview Questions & Answers for 2026
- GeeksforGeeks — Top 50+ Ansible Interview Questions and Answers
- Vinsys — Top 30 Ansible Interview Questions and Answers 2026
- K21 Academy — Ansible Interview Questions & Answers 2026
- Hirist — Top 25+ Ansible Interview Questions and Answers 2026
Answers were rewritten and deepened rather than reproduced — published versions are usually correct but shallow, and the added operational detail is what differentiates a candidate in the room.