Ansible Lesson 8 of 42

Ansible Conditionals, Loops, Handlers & Tags, In Depth

A playbook that runs every task on every host every time is a shell script with extra steps. The thing that turns Ansible from a remote-command runner into a configuration-management tool is flow control: the ability to run a task only when it makes sense (when), to run it once per item in a list (loop), to run a task only because something else changed (handlers via notify), and to run only the part of a playbook you care about right now (tags). These four features are what an RHCE exam probes relentlessly and what separates a playbook that limps along from one you trust against 400 production hosts. This lesson covers all four exhaustively — every keyword, every option, every gotcha — at the same depth as a certification study guide, but starting from first principles so you can follow even if when is new to you.

By the end you will know exactly when a handler fires (and the three ways it can fail to), why loop replaced with_items, how loop_control renames and labels and paces a loop, how a list of when conditions is silently an AND, and precisely what always and never tags do that no other tag does.

Learning objectives

Prerequisites & where this fits

You should already be comfortable with the playbook anatomy — plays, tasks, modules, become — and with variables and facts (registering task output, referencing ansible_facts, the register keyword). If those are shaky, read the preceding lesson, Ansible Variables & Facts, In Depth, first, because conditionals and loops live or die on referencing variables correctly. This is lesson B2 in the Intermediate tier of the KloudVin Ansible Zero-to-Hero course, in the Playbooks module. Everything here is ansible.builtin and ships with ansible-core (2.17+ assumed, Ansible 10+ in 2026) — no collections to install. The next lesson, Ansible Jinja2 Templating, In Depth, builds directly on the expression syntax you meet here, because a when: clause is a Jinja2 expression.

Core concepts

Four mental models carry this entire lesson:

Keep those four sentences in your head and the rest is detail.


when: conditionals in depth

when is a task-level (or block-level) keyword that decides whether a task runs on a given host. It is evaluated per host, after facts are gathered, using that host’s variables and facts. If it evaluates false, the task is skipped for that host (you see skipping: [host] and it counts in the skipped= recap), and any register for that task records a result with skipped: true.

The cardinal rule: no {{ }} around the whole expression

# CORRECT — bare expression
- name: Install Apache on RHEL-family hosts
  ansible.builtin.package:
    name: httpd
    state: present
  when: ansible_facts['os_family'] == "RedHat"

# WRONG — double-templating; works by luck sometimes, fails on bare vars
- name: Don't do this
  ansible.builtin.package:
    name: httpd
    state: present
  when: "{{ ansible_facts['os_family'] == 'RedHat' }}"

You do still use {{ }} for a value embedded in a larger string inside when (rare), but for the expression as a whole, never. Wrapping a bare variable in braces — when: "{{ my_bool }}" — produces a deprecation warning and can mis-evaluate truthiness.

Testing variables, facts, and registered results

A when expression can reference anything in scope: play vars, host/group vars, facts (ansible_facts[...] or the older ansible_* top-level names), magic vars (inventory_hostname, groups, hostvars), and registered results from earlier tasks.

- name: Capture whether the config file exists
  ansible.builtin.stat:
    path: /etc/myapp/config.ini
  register: cfg

- name: Seed a default config only if none exists
  ansible.builtin.copy:
    src: default-config.ini
    dest: /etc/myapp/config.ini
  when: not cfg.stat.exists

stat always “succeeds” and returns stat.exists, so when: not cfg.stat.exists is the idiomatic “create-if-absent” guard.

Boolean operators and grouping

You have the full Python/Jinja2 boolean vocabulary: and, or, not, and parentheses for grouping. Comparison operators are ==, !=, <, >, <=, >=, and membership with in / not in.

when: ansible_facts['distribution'] == "Ubuntu" and ansible_facts['distribution_major_version'] | int >= 22

when: (env == "prod" or env == "staging") and not maintenance_mode

when: "'webservers' in group_names"        # is this host in the webservers group?

when: ansible_facts['memtotal_mb'] | int > 4096

Two things bite people here. First, numeric comparisons need | int (or | float) — facts and vars are often strings ("22" not 22), and "9" > "10" is true as a string comparison. Always cast: ... | int >= 22. Second, when a value contains a quote or starts with something YAML wants to interpret, quote the whole when string and use the inner quotes for the literal, as in the 'webservers' in group_names example.

The list-of-conditions = AND shorthand

If you give when a YAML list, every item must be true — it is an implicit AND. This is the cleanest way to write multi-condition guards and is heavily tested:

- name: Restart only on prod RHEL hosts that aren't frozen
  ansible.builtin.service:
    name: httpd
    state: restarted
  when:
    - ansible_facts['os_family'] == "RedHat"
    - env == "prod"
    - not change_freeze | default(false)

That is equivalent to joining the three with and. There is no implicit OR list — for OR you must write or inline (or invert the logic with not (... and ...)).

Jinja2 tests: is defined, is failed, is changed, and friends

Ansible ships Jinja2 tests (used with is / is not) that are indispensable in when. The most important:

Test True when… Typical use
is defined / is not defined the variable exists / doesn’t when: my_var is defined guard before using a var
is none the value is None/null distinguish “set to null” from “undefined”
is succeeded / is success a registered result succeeded gate a follow-up step on success
is failed / is failure a registered task failed (usually with ignore_errors) run recovery only on failure
is changed / is change a registered task reported changed trigger work only when something actually changed
is skipped a registered task was skipped (its own when was false) branch on a skipped predecessor
is match / is search / is regex string matches a regex (anchored / anywhere / custom) pattern-test a fact
is version(...) version comparison, e.g. is version('2.0','>=') compare software versions properly
is in membership (Jinja2 2.10+) when: item is in allowed_list

The register + is failed/is changed combination is the backbone of error handling and handler-free conditionals:

- name: Try the graceful reload
  ansible.builtin.command: systemctl reload myapp
  register: reload_result
  ignore_errors: true

- name: Fall back to a full restart if reload failed
  ansible.builtin.service:
    name: myapp
    state: restarted
  when: reload_result is failed

Note the difference between is not defined and is none: is not defined means the name was never set; is none means it was set to null. Use var | default(...) to collapse both into a usable default. And prefer the tests is defined/is failed over the older string forms is defined/failed — the is test syntax is current and clearer than result.failed.

when together with loop: per-item evaluation

When a task has both when and a loop, the when is re-evaluated for every item, and item is in scope inside the condition. Items that fail the test are skipped individually:

- name: Create only the users flagged active
  ansible.builtin.user:
    name: "{{ item.name }}"
    state: present
  loop:
    - { name: "alice", active: true }
    - { name: "bob",   active: false }
  when: item.active

A subtle exam point: if you reference a registered result’s .stdout (a single value) in a when on a loop, the condition is still evaluated per item — but the registered var only has the single (last) value, not per-item. To branch per item on a previous loop’s results, loop over that result’s .results list (see the loop section). Also remember: when is evaluated before the loop is expanded for the task as a whole only in the sense of variable scope — practically, treat it as “checked for each item”.

when on blocks, roles, and includes

when applied to a block is inherited by every task in the block (each task still evaluates it, and a task’s own when is ANDed with the block’s). when on include_role/include_tasks (dynamic includes) gates the inclusion and is applied to every included task. when on import_tasks/import_role (static imports) is added to every imported task’s own when at parse time — so if imported tasks have their own conditions, they are ANDed. This distinction (include = gate at runtime; import = condition pushed onto each child) is a classic gotcha.

Construct What when does
task run/skip this task on this host
block condition inherited by (ANDed onto) every task in the block
include_tasks / include_role (dynamic) evaluated once at runtime to decide whether to include
import_tasks / import_role (static) pushed down and ANDed onto every imported task’s when

Combining when with facts you must gather first

when runs after gather_facts (default on), so ansible_facts is populated. If you set gather_facts: false for speed, any when that references a fact will treat it as undefined and likely error or skip. Either gather facts, gather a subset (gather_subset), or guard with is defined.


Loops in depth: loop, with_*, and loop_control

A loop runs one task repeatedly, once per item, with the current value bound to item by default. Ansible has two loop syntaxes: the modern loop keyword and the legacy with_<lookup> family. Since Ansible 2.5, loop is the recommended form; with_* still works and you will meet it in older code, so you must read both.

loop: the modern way

loop takes a list. The list can be inline, a variable, or the output of a filter/lookup.

- name: Install a set of packages (one task, many items)
  ansible.builtin.package:
    name: "{{ item }}"
    state: present
  loop:
    - git
    - curl
    - vim

- name: Same, from a variable
  ansible.builtin.package:
    name: "{{ item }}"
    state: present
  loop: "{{ package_list }}"

A performance note worth knowing: many modules (package, dnf, apt, yum, user for some ops) accept a list directly in their name: argument, which is far faster than looping because it is one transaction. Prefer name: "{{ package_list }}" over loop when the module supports a list — loop only when it doesn’t.

Looping over a list of dictionaries

The list items can be dicts; reference fields with item.field:

- name: Create users with their groups
  ansible.builtin.user:
    name: "{{ item.name }}"
    groups: "{{ item.groups }}"
    state: present
  loop:
    - { name: "alice", groups: "wheel" }
    - { name: "bob",   groups: "developers" }

Looping over a dictionary with dict2items

loop needs a list, so to iterate a dictionary you convert it with the dict2items filter, which yields a list of { key, value } pairs:

- name: Set sysctl values from a dict
  ansible.posix.sysctl:
    name: "{{ item.key }}"
    value: "{{ item.value }}"
    state: present
  loop: "{{ tuning | dict2items }}"
  # tuning: { net.ipv4.ip_forward: 1, vm.swappiness: 10 }

The reverse, items2dict, rebuilds a dict from such a list. For nested loops (a list of dicts each containing a list), use the subelements filter or the product/flatten filters rather than nesting loop (Ansible does not support a literal nested loop on one task).

loop_control: shaping the loop

loop_control is a dictionary of sub-keys that change loop behaviour without changing what you iterate. This is exam-favourite territory — know every key.

loop_control key What it does Example / notes
loop_var renames the per-item variable from item to a name you choose Essential for nested includes so an outer and inner loop don’t both use item
label controls what is printed for each iteration in task output label: "{{ item.name }}" hides huge dicts / secrets from the console
index_var binds the current 0-based index to a variable index_var: idx → use {{ idx }}; add 1 for human counts
pause seconds to wait between iterations pause: 3 to rate-limit API calls or rolling actions
extended exposes ansible_loop with rich metadata gives .index, .index0, .first, .last, .length, .revindex, .allitems, .previtem, .nextitem
extended_allitems with extended, controls whether ansible_loop.allitems is populated set false to save memory on huge loops
- name: Roll through app servers, one at a time, with readable labels
  ansible.builtin.uri:
    url: "https://{{ item.host }}/health"
  loop: "{{ app_servers }}"
  loop_control:
    loop_var: server          # 'item' would clash if this were inside an include
    label: "{{ item.host }}"  # print just the host, not the whole dict
    index_var: i              # 0,1,2,...
    pause: 5                  # 5s between each, a poor-man's rolling check
    extended: true           # gives ansible_loop.first / .last / .length

With extended: true, ansible_loop.first and ansible_loop.last let you do “only on the first/last item” logic inside a loop — handy for printing a header once or a summary at the end.

loop_var and nested includes is the single most important loop_control use. If an include_tasks is itself in a loop and the included file also loops, both default to item and the inner clobbers the outer. Rename the outer with loop_var (e.g. loop_var: outer_item) to keep them distinct.

register inside a loop: the .results list

When you register a task that loops, the registered variable’s top-level fields are mostly meaningless; the per-iteration data lives in .results, a list with one entry per item. Each entry has that iteration’s item, rc, stdout, changed, failed, etc.

- name: Check several URLs
  ansible.builtin.uri:
    url: "{{ item }}"
    status_code: 200
  loop:
    - https://a.example.com
    - https://b.example.com
  register: url_checks

- name: Report any that were not 200
  ansible.builtin.debug:
    msg: "DOWN: {{ item.item }} returned {{ item.status }}"
  loop: "{{ url_checks.results }}"
  when: item.status != 200

Note item.item in the second loop: the outer item is a result entry, and .item inside it is the original value that produced that result. This pattern — loop over .results, branch with when — is how you act per item on a previous loop’s outcome.

until / retries / delay: the retry loop

A different kind of loop: retry one task until a condition is met. until is a when-style expression; the task repeats up to retries times with delay seconds between attempts. The task is considered failed if it never satisfies until.

- name: Wait for the app to report healthy
  ansible.builtin.uri:
    url: http://localhost:8080/health
    status_code: 200
  register: health
  until: health.status == 200
  retries: 12          # up to 12 attempts...
  delay: 5             # ...5 seconds apart (so ~1 minute max)
  # default retries=3, delay=5 if omitted

Defaults are retries: 3 and delay: 5. The registered result gains an attempts field telling you how many tries it took. until cannot be combined with loop/with_* on the same task — it is the loop. Prefer until/retries over the dedicated wait_for/wait_for_connection modules when you are polling an application-level condition rather than a port or the SSH connection.

The legacy with_* family and the lookup mapping

Before loop, every loop used with_<plugin>, where the suffix is a lookup plugin name. They still work, but the docs (and RHCE-era best practice) steer you to loop for the common cases. The crucial knowledge is the translation table: every with_* maps to a loop: expression, usually via a filter or lookup.

Legacy Iterates over Modern loop equivalent
with_items a (flattened-one-level) list loop: "{{ mylist }}" (use | flatten(levels=1) if you relied on flattening)
with_list a list, without flattening loop: "{{ mylist }}"
with_dict a dict → {key,value} loop: "{{ mydict | dict2items }}"
with_fileglob files matching a glob loop: "{{ query('fileglob', '/path/*.conf') }}"
with_together parallel lists (zip) loop: "{{ list_a | zip(list_b) | list }}"
with_sequence a numeric sequence loop: "{{ range(1, 11) | list }}" (or keep with_sequence)
with_subelements a list + a sub-list per element loop: "{{ parent | subelements('children') }}"
with_nested cartesian product of lists loop: "{{ list_a | product(list_b) | list }}"
with_flattened deeply nested lists, flattened loop: "{{ nested | flatten }}"
with_first_found first existing file in a list loop: "{{ query('first_found', candidates) }}"
with_random_choice one random element loop: "{{ [ mylist | random ] }}" (or | random directly)
with_indexed_items list with index loop + loop_control.index_var
with_lines lines of a command’s output loop: "{{ query('lines', 'cmd') }}"

The mechanical rule: with_X: foo is loop: "{{ lookup('X', foo) }}" for most plugins — but lookup() returns a comma-joined string by default, whereas query() (alias q()) returns a proper list, which is what loop wants. So in practice the safe conversion is loop: "{{ query('X', foo) }}". The simple value-list cases (with_items, with_list) just become loop: "{{ var }}". Don’t mass-migrate working with_* for its own sake; do use loop for new code and convert when touching old code.

One behavioural difference to flag: with_items flattens one level of nested lists; loop does not. If you had a list-of-lists and relied on with_items flattening it, add | flatten(levels=1) when moving to loop.


Handlers in depth: notify, when they run, listen, and flush_handlers

A handler is a task defined under a play’s handlers: section (or in a role’s handlers/main.yml) that runs only when notified by a task that reported changed. Handlers are how you do “if (and only if) I changed the config, restart the service” — and do it once, at the right time.

The rules — memorise these

  1. A handler runs only if a task notifys it. No notify, no run.
  2. It runs only if the notifying task reported changed: true. If the task was ok (idempotent no-op) or skipped, the notification is not sent. This “only on change” behaviour is the entire point.
  3. It runs at the END of the play, after all tasks in the play complete — not at the point of notification.
  4. It runs once, no matter how many tasks notified it. Ten config edits that all notify: restart httpd → one restart.
  5. Handlers run in the order they are defined in the handlers: section, not the order they were notified. (This trips everyone up at least once.)
  6. By default, if any task in the play fails, pending handlers do NOT run (the play aborts before the handler flush) — unless you use force_handlers (below).
- name: Configure and (re)start nginx
  hosts: webservers
  become: true
  tasks:
    - name: Deploy nginx.conf
      ansible.builtin.template:
        src: nginx.conf.j2
        dest: /etc/nginx/nginx.conf
      notify: Restart nginx        # fires only if this template task CHANGED the file

    - name: Deploy a virtual host
      ansible.builtin.template:
        src: site.conf.j2
        dest: /etc/nginx/conf.d/site.conf
      notify: Restart nginx        # notifies the SAME handler again — still one restart

  handlers:
    - name: Restart nginx
      ansible.builtin.service:
        name: nginx
        state: restarted

If neither template changed anything, the handler never runs — exactly what you want on a re-run.

Notifying multiple handlers

A task can notify several handlers with a list:

- name: Update the app config
  ansible.builtin.template:
    src: app.conf.j2
    dest: /etc/app/app.conf
  notify:
    - Reload app
    - Bump metrics counter

Both run (in definition order) at the end of the play, once each, if the task changed.

listen: topic-based notification

A handler can subscribe to a topic with listen, and a task notifies the topic rather than a specific handler name. Every handler listening to that topic runs. This decouples the notifier from the handler names and is the clean way to fan one event out to several actions:

  handlers:
    - name: Restart nginx
      ansible.builtin.service: { name: nginx, state: restarted }
      listen: "reload web stack"

    - name: Flush the CDN cache
      ansible.builtin.command: /usr/local/bin/purge-cdn
      listen: "reload web stack"
    - name: Change something web-related
      ansible.builtin.template: { src: x.j2, dest: /etc/nginx/x }
      notify: "reload web stack"     # triggers BOTH handlers above

Key facts about listen: a task notifying a topic triggers all handlers listening on it; a handler can have both a name and a listen (you can notify it by either); and multiple handlers can share a topic. This is the preferred pattern in roles, because a role can expose stable topics (e.g. "restart web services") without callers needing to know individual handler names.

meta: flush_handlers — run pending handlers now

Sometimes you cannot wait until the end of the play — e.g. you change a config, must restart the service, then run a task that depends on the service being up. meta: flush_handlers forces all currently pending (notified) handlers to run immediately at that point:

    - name: Write the new listen port
      ansible.builtin.lineinfile:
        path: /etc/app/app.conf
        regexp: '^port='
        line: 'port=9000'
      notify: Restart app

    - name: Apply the restart right now, not at play end
      ansible.builtin.meta: flush_handlers

    - name: Now hit the app on its new port (needs it already restarted)
      ansible.builtin.uri:
        url: http://localhost:9000/health
        status_code: 200
      register: h
      until: h.status == 200
      retries: 10
      delay: 3

After a flush, those handlers have run and won’t run again at play end unless re-notified. meta: tasks like flush_handlers always run (they ignore when-skips in the usual sense — a when on a meta task is honoured, but meta itself isn’t a module on a target). Other useful meta values nearby: clear_host_errors, end_play, end_host, noop — but flush_handlers is the one tied to handlers.

force_handlers and --force-handlers: run handlers even after a failure

By rule 6, a play failure normally skips pending handlers. If you need the handler to run even when a later task fails (e.g. you changed config and must restart regardless), set force_handlers: true on the play, or pass --force-handlers on the command line, or set force_handlers = True in ansible.cfg. With it on, notified handlers run for hosts that haven’t failed, even though the play is failing.

- name: Resilient config push
  hosts: webservers
  become: true
  force_handlers: true     # restart even if a later task blows up
  tasks: ...
  handlers: ...

Be deliberate: forcing handlers can mean restarting a service into a half-configured state, so it is a conscious resilience trade-off, not a default.

Handler edge cases and gotchas

Behaviour Detail
Run condition only if a notifying task reported changed (not ok, not skipped)
Timing end of play (or at flush_handlers), not at notify time
Count once per handler regardless of how many notifies
Order definition order in handlers:, not notification order
Notify a missing handler by default an error (The requested handler 'X' was not found); a typo in the name silently never runs only if error_on_missing_handler=false
Failure pending handlers skipped unless force_handlers/--force-handlers
when on a handler yes — a handler can have its own when, evaluated when it would run
Loops in handlers yes — handlers can loop like any task
Handler names must be unique within their scope to be notify-able by name; use listen to avoid name coupling
Re-notify after flush a handler that ran at flush_handlers runs again at play end only if notified again afterwards
--check mode handlers are notified on would-change and “run” in check mode (reported, not executed)

A frequently-missed point: the notify name must exactly match the handler’s name (case- and space-sensitive). A mismatch raises “handler not found” by default — which is actually helpful, because it catches typos. Set error_on_missing_handler = false only if you intentionally notify handlers that may not be defined.


Tags in depth: selecting and skipping parts of a play

Tags are labels you attach to tasks, blocks, plays, or roles so you can run or skip subsets with --tags / --skip-tags. On a big playbook, tags turn a 20-minute full run into a 30-second “just the nginx config” run.

Adding tags at every level

- name: Web tier
  hosts: webservers
  tags: web                      # PLAY-level: applied to every task in the play
  tasks:
    - name: Install packages
      ansible.builtin.package:
        name: [nginx, certbot]
        state: present
      tags:
        - packages               # TASK-level: this task carries 'packages' (and 'web')

    - name: Configuration block
      tags: config               # BLOCK-level: inherited by both tasks below
      block:
        - name: Main config
          ansible.builtin.template: { src: nginx.conf.j2, dest: /etc/nginx/nginx.conf }
        - name: TLS config
          ansible.builtin.template: { src: tls.conf.j2, dest: /etc/nginx/conf.d/tls.conf }

  roles:
    - role: monitoring
      tags: observability        # ROLE-level: applied to every task the role contributes

Tags are additive and inherited downward: a task gets its own tags plus any from its enclosing block, play, and role. So in the example, the “Main config” task effectively carries config and web. There is no way to remove an inherited tag from a child — inheritance is one-way.

Running and skipping by tag

Command Runs
ansible-playbook site.yml --tags config only tasks tagged config (plus any tagged always)
ansible-playbook site.yml --tags "config,packages" tasks tagged config or packages (plus always)
ansible-playbook site.yml --skip-tags config everything except tasks tagged config
ansible-playbook site.yml --tags web --skip-tags config web-tagged tasks but not the config ones among them
ansible-playbook site.yml (no tag flags) everything except tasks tagged only never

--tags and --skip-tags accept comma-separated lists (or --tags=a --tags=b). --tags is OR across the listed tags (a task runs if it has any of them). When you supply --tags, tasks without a matching tag are skipped — except always.

The four special tags

This is the part exams love. Four tag names have built-in meaning:

Special tag Meaning
always The task runs on every invocation, regardless of --tags, unless you explicitly --skip-tags always. Use for must-always-run setup (gather a fact, sanity check, set a var the rest depends on).
never The task runs only when its tag (or never itself) is explicitly requested with --tags. Otherwise it is always skipped. Use for dangerous/expensive tasks (a destructive cleanup, a full reindex) you want opt-in.
tagged A selector you pass on the CLI: --tags tagged runs every task that has any tag at all (and skips untagged tasks). The inverse is --skip-tags tagged.
all The default selector: --tags all runs everything (the implicit behaviour with no --tags). Rarely typed, but it is what “no --tags” means.
    - name: Always validate the inventory has a db host
      ansible.builtin.assert:
        that: groups['db'] | length > 0
      tags: always              # runs even under --tags config

    - name: Wipe and rebuild the search index (dangerous)
      ansible.builtin.command: /usr/local/bin/reindex --force
      tags: never               # runs ONLY if you pass --tags reindex (or --tags never)

    - name: This same task can also be opted into by a friendly name
      ansible.builtin.command: /usr/local/bin/reindex --force
      tags:
        - never
        - reindex               # so `--tags reindex` triggers it; a bare run skips it

The never + a friendly tag combination is the standard idiom for an opt-in task: it is skipped by default, and you trigger it deliberately with --tags reindex. Note that --tags always is implied on every run, and the only way to suppress an always task is --skip-tags always.

Listing tags and dry-running selection

Tag inheritance with imports vs includes

Mirroring when: with import_role/import_tasks (static, parse-time) the tag is applied to every child task, so --tags x on the parent reaches the children. With include_role/include_tasks (dynamic, runtime) the tag is on the include statement, so to run the included tasks you must select the include’s tag — the children’s own tags aren’t visible to --list-tags until the include runs. If you want a role’s internal tags to be selectable from the CLI, import it (or pass apply: { tags: [...] } on a dynamic include to push tags onto the included tasks).


Diagram

Ansible flow control: when conditionals, loop and loop_control, handler notify timing, and tag selection

The diagram traces one play through all four mechanisms: a task’s when decides run-vs-skip per host; a loop fans the task across items (with loop_control renaming/labelling/pacing each); a changed task fires a notify that is queued and flushed once at the end of the play (or early via flush_handlers); and the --tags/--skip-tags selector on the left gates which tasks are even considered — with always always in and never out unless named.

Hands-on lab

You will exercise all four features on localhost plus a couple of throwaway containers — no cloud, no cost. You need ansible-core and either Docker or Podman. We will use the community.docker collection only to create the practice containers; the flow-control features themselves are pure ansible.builtin.

Step 0 — set up

# Confirm ansible-core
ansible --version            # expect 2.17 or newer

# Install the docker collection used only to spin up practice hosts
ansible-galaxy collection install community.docker

# A tiny inventory: localhost + two containers we will create
mkdir -p ~/ansible-b2 && cd ~/ansible-b2

Create inventory.ini:

[local]
localhost ansible_connection=local

[demo]
node1 ansible_connection=community.docker.docker
node2 ansible_connection=community.docker.docker

Step 1 — create the practice containers

Create setup.yml:

- name: Spin up two practice containers
  hosts: localhost
  connection: local
  gather_facts: false
  tasks:
    - name: Run lightweight containers we can target
      community.docker.docker_container:
        name: "{{ item }}"
        image: python:3.12-slim     # has python so Ansible modules work
        command: sleep infinity
        state: started
      loop:
        - node1
        - node2
ansible-playbook setup.yml
# Expect: changed for each container (or ok on a re-run — idempotent)

Step 2 — the flow-control playbook

Create flow.yml. It uses when, loop + loop_control, a handler with notify/listen/flush_handlers, and tags including always and never.

- name: Demonstrate when / loop / handlers / tags
  hosts: demo
  gather_facts: true
  vars:
    packages:
      - { name: "curl",  wanted: true }
      - { name: "git",   wanted: true }
      - { name: "nano",  wanted: false }   # will be skipped by 'when'
  tasks:
    - name: ALWAYS announce which host we are on
      ansible.builtin.debug:
        msg: "Configuring {{ inventory_hostname }} ({{ ansible_facts['distribution'] }})"
      tags: always

    - name: Install only the wanted packages (loop + per-item when + labels)
      ansible.builtin.apt:
        name: "{{ item.name }}"
        state: present
        update_cache: true
      loop: "{{ packages }}"
      when: item.wanted
      loop_control:
        label: "{{ item.name }}"      # console shows just the name
        index_var: idx
      register: pkg_results
      notify: Note config changed     # fires only if any install actually changed
      tags: packages

    - name: Show how many packages were processed
      ansible.builtin.debug:
        msg: "Processed {{ pkg_results.results | length }} package item(s)"
      tags: packages

    - name: Write a marker file (will notify two handlers via a topic)
      ansible.builtin.copy:
        dest: /tmp/configured.txt
        content: "configured at {{ ansible_date_time.iso8601 | default('now') }}\n"
      notify: "post-config"           # topic; both listeners fire on change
      tags: config

    - name: Flush handlers now so the verify step sees their effect
      ansible.builtin.meta: flush_handlers
      tags: config

    - name: Verify the marker exists (proves handler-driven ordering)
      ansible.builtin.command: cat /tmp/configured.txt
      register: marker
      changed_when: false
      tags: config

    - name: DANGEROUS task that only runs when explicitly requested
      ansible.builtin.command: "rm -f /tmp/configured.txt"
      tags:
        - never
        - wipe                        # opt-in only via --tags wipe

  handlers:
    - name: Note config changed
      ansible.builtin.debug:
        msg: "A package install changed something on {{ inventory_hostname }}"

    - name: Echo the post-config topic A
      ansible.builtin.debug:
        msg: "post-config handler A ran"
      listen: "post-config"

    - name: Echo the post-config topic B
      ansible.builtin.debug:
        msg: "post-config handler B ran"
      listen: "post-config"

Step 3 — run it and read the output

ansible-playbook -i inventory.ini flow.yml

Expected observations:

Now exercise tags:

# Only the config-tagged tasks (plus the 'always' debug):
ansible-playbook -i inventory.ini flow.yml --tags config
# You'll see the ALWAYS task run even though you only asked for 'config'.

# Everything except packages:
ansible-playbook -i inventory.ini flow.yml --skip-tags packages

# List what tags exist, and what would run for a selection:
ansible-playbook -i inventory.ini flow.yml --list-tags
ansible-playbook -i inventory.ini flow.yml --tags config --list-tasks

# The 'never' task is normally invisible; opt in explicitly:
ansible-playbook -i inventory.ini flow.yml --tags wipe
# Now (and only now) the rm runs.

Validation

# After a --tags config run, the marker should exist on both nodes:
ansible -i inventory.ini demo -m ansible.builtin.command -a "cat /tmp/configured.txt"

# After a --tags wipe run, it should be gone:
ansible -i inventory.ini demo -m ansible.builtin.stat -a "path=/tmp/configured.txt" \
  | grep -E '"exists": (true|false)'

Cleanup

cat > teardown.yml <<'YAML'
- hosts: localhost
  connection: local
  gather_facts: false
  tasks:
    - name: Remove practice containers
      community.docker.docker_container:
        name: "{{ item }}"
        state: absent
      loop: [node1, node2]
YAML
ansible-playbook teardown.yml
rm -rf ~/ansible-b2

Cost note

₹0. Everything runs on localhost and two local containers using a public base image. No cloud resources are created, so there is nothing to bill and nothing left running after teardown.

Common mistakes & troubleshooting

Symptom Likely cause Fix
when always true / a string like "false" is truthy wrapped the expression in {{ }}, or compared a string to a number drop the braces; cast with | int/| bool
'dict object' has no attribute 'X' in when/loop referenced a var/field that may be undefined guard with is defined or use | default(...)
Numeric comparison gives wrong result facts/vars are strings ("10" < "9") ... | int (or | float) on both sides
Handler never runs notifying task reported ok (idempotent), not changed that’s correct behaviour; to force, fix the task or use changed_when deliberately
The requested handler 'X' was not found notify name doesn’t exactly match handler name match case/spacing exactly, or use listen topics
Handler runs in the “wrong” order handlers run in definition order, not notify order reorder the handlers: section, or split into topics
Pending handlers skipped after a failure a later task failed; handlers are skipped by default set force_handlers: true or pass --force-handlers
Restart happened too late you needed it mid-play insert ansible.builtin.meta: flush_handlers
--tags x runs nothing no task carries tag x, or tags are only on a dynamic include’s children --list-tags; import the role/tasks (or use apply: { tags: }) to expose child tags
A task you didn’t select still ran it is tagged always that’s intended; suppress with --skip-tags always
never-tagged task ran unexpectedly you passed --tags all or its own tag only request its tag when you mean it
Inner loop overwrites outer item nested loop/include both use default item set loop_control.loop_var on the outer loop
Looping a module that takes a list is slow one network round-trip per item pass the list straight to name: instead of loop

Best practices

Security notes

Interview & exam questions

  1. Do you wrap a when expression in {{ }}? No. when takes a bare Jinja2 expression; Ansible templates it implicitly. Wrapping the whole thing in {{ }} is wrong, triggers a deprecation warning, and can mis-evaluate bare booleans. (You may still use {{ }} for a value embedded inside a larger string within the expression, but not for the expression as a whole.)
  2. What does a list of conditions under when mean? Logical AND — every item must be true. It is shorthand for joining them with and. There is no implicit-OR list; for OR you write or inline.
  3. When exactly does a handler run? Only when (a) a task notifies it and (b) that task reported changed; it runs once, at the end of the play (or at a meta: flush_handlers), in the order handlers are defined — not the order they were notified.
  4. Two tasks both notify: restart nginx, both change. How many restarts? One. Handlers are de-duplicated and run once per play regardless of how many notifications they receive — which is the whole reason handlers exist.
  5. A handler is defined but never runs even though you expected a change. Why might that be? The notifying task likely reported ok (idempotent no-op) or skipped, so no notification was sent; or the notify name doesn’t exactly match the handler name; or the play failed before the handler flush (and force_handlers is off).
  6. What does meta: flush_handlers do and when do you need it? It runs all currently-pending handlers immediately at that point in the play, instead of waiting until the end — needed when a subsequent task depends on the handler’s effect (e.g. restart the service, then health-check it within the same play).
  7. Explain listen versus notify by name. A handler can subscribe to a topic with listen; a task notifies the topic, and all handlers listening on it run. This decouples notifiers from handler names — the preferred pattern in roles, which expose stable topics rather than internal names.
  8. What is the difference between always and never tags? always-tagged tasks run on every invocation regardless of --tags, unless you --skip-tags always. never-tagged tasks never run unless you explicitly request their tag (or never) via --tags — the standard idiom for opt-in dangerous/expensive tasks.
  9. How do loop and with_items differ behaviourally? loop is the current recommended keyword and does not flatten nested lists; with_items flattens one level. Functionally most with_* map to a loop over a query('<plugin>', ...) expression; with_items/with_list simply become loop: "{{ var }}". Add | flatten(levels=1) if you relied on with_items flattening.
  10. How do you loop over a dictionary? loop needs a list, so convert with dict2items: loop: "{{ mydict | dict2items }}", then reference item.key and item.value. items2dict is the reverse.
  11. What does loop_control give you? Per-loop controls: loop_var (rename item, essential for nested includes), label (what prints per iteration — hide big/secret data), index_var (0-based index), pause (seconds between iterations), and extended (exposes ansible_loop with .first/.last/.length/.index/etc.).
  12. How do until/retries/delay work, and how does that differ from loop? They retry a single task until the until expression is true, up to retries attempts (default 3) delay seconds apart (default 5); the result gains an attempts count. It is a retry loop, not an iteration loop, and cannot be combined with loop on the same task.
  13. You register a looped task — where are the per-item results? In the registered variable’s .results list, one entry per item, each carrying that iteration’s item, rc, stdout, changed, etc. To act per item, loop over .results and reference item.item for the original value.
  14. A role’s internal tags aren’t selectable from --tags. Why, and how do you fix it? The role was pulled in with a dynamic include_role, so its child tasks’ tags live behind the include and aren’t visible at parse time. Use import_role (static, pushes tags onto every child) or pass apply: { tags: [...] } on the dynamic include.

Quick check

  1. True or false: you should write when: "{{ x == 'prod' }}".
  2. In what order do handlers run — the order they were notified, or the order they are defined?
  3. Which loop_control key do you set to stop a nested loop/include from clobbering the outer item?
  4. What command-line flag makes notified handlers run even though a later task failed?
  5. Which special tag makes a task run on every invocation regardless of --tags, and which makes it run only when explicitly named?

Answers

  1. False. when takes a bare expression: when: x == 'prod'. Wrapping it in {{ }} is wrong (deprecation warning, possible mis-evaluation).
  2. Definition order — the order handlers appear in the handlers: section, not the order tasks notified them.
  3. loop_var (set it on the outer loop, e.g. loop_var: outer_item).
  4. --force-handlers (or force_handlers: true on the play, or force_handlers = True in ansible.cfg).
  5. always runs on every invocation (unless --skip-tags always); never runs only when its tag (or never) is explicitly requested via --tags.

Exercise

Write a single playbook webserver.yml for hosts: webservers that demonstrates all four mechanisms together. (a) Use a dict variable vhosts mapping site name → server-name, and a loop over dict2items to template one config file per vhost into /etc/nginx/conf.d/{{ item.key }}.conf, using loop_control.label: "{{ item.key }}". (b) Make every templating task notify a single topic reload nginx via listen, with two handlers on that topic: one that runs nginx -t (config test, changed_when: false) and one that reloads the service — and ensure the test runs before the reload by ordering. © Add a when so the whole vhost block only runs on hosts where ansible_facts['os_family'] == "RedHat", expressed as a list-of-conditions that also checks a deploy_vhosts | default(true) flag. (d) Tag the install step packages, the vhost block config, and add an always-tagged assert that vhosts | length > 0. (e) Add a never-tagged task (also tagged purge) that deletes all files in /etc/nginx/conf.d/. Then write the three commands that (i) run only the config part, (ii) list which tasks would run for --tags config, and (iii) explicitly trigger the purge. In two sentences, explain why nginx -t must be ordered before the reload handler and why the purge is tagged never.

Certification mapping

Glossary

Next steps

You can now control whether a task runs (when), how many times (loop/with_* with loop_control, plus until retries), when side-effects fire (handlers via notify/listen/flush_handlers/force_handlers), and which parts of a playbook execute (tags, including always/never). The next lesson, Ansible Jinja2 Templating, In Depth, goes deep on the expression language underneath all of this — the same {{ }}/{% %} syntax, the filters (default, map, select, dict2items, combine) and tests you used in when and loop, plus the template module for generating config files. After that, Ansible Error Handling, In Depth builds on conditionals and handlers with blocks, rescue/always, failed_when, changed_when, and any_errors_fatal for genuinely resilient playbooks. To revisit where the variables in your conditions come from, return to Ansible Variables & Facts, In Depth.

ansibleplaybooksconditionalsloopshandlerstags
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments