Skip to main content
Ansible · lesson 4 of 6
Ansible intermediate Lesson 4 of 6

Loops, Conditionals, and block/rescue: Reading the Real Recap Numbers

A loop over 3 items reports changed=1, not 3 — the recap counts hosts, not items. Plus when-conditionals that skip, and block/rescue/always catching a bug.

By SkillByExample · Updated

ansible loops conditionals error-handling blocks

Three cmdlets worth of playbook control flow, and one of them has a summary number that means something different from what it looks like.

Looping over a list

# loop_playbook.yml
---
- name: Loop over a list
  hosts: local
  tasks:
    - name: Create multiple directories
      file:
        path: "/tmp/loopdirs/{{ item }}"
        state: directory
      loop:
        - alpha
        - beta
        - gamma
ansible-playbook -i inventory.ini loop_playbook.yml
TASK [Create multiple directories] *********************************************
changed: [localhost] => (item=alpha)
changed: [localhost] => (item=beta)
changed: [localhost] => (item=gamma)

PLAY RECAP *********************************************************************
localhost                  : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Three items, three real changed results printed per-iteration — and the PLAY RECAP at the bottom says changed=1, not 3. That’s not a bug or an undercount: the recap summarizes per host, not per iteration. changed=1 means “this one host had at least one changed result this play” — for a count of individual item-level changes, the per-item lines above the recap are the actual source of truth, not the summary line.

Conditionals with when

# when_playbook.yml
---
- name: Conditional tasks
  hosts: local
  vars:
    deploy_env: production
  tasks:
    - name: Only in production
      debug:
        msg: "Deploying to prod"
      when: deploy_env == "production"
    - name: Only in staging
      debug:
        msg: "Deploying to staging"
      when: deploy_env == "staging"
ansible-playbook -i inventory.ini when_playbook.yml
TASK [Only in production] ******************************************************
ok: [localhost] => {
    "msg": "Deploying to prod"
}

TASK [Only in staging] *********************************************************
skipping: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=2    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0

The staging task never ran at all — skipping, not ok and not failed. skipped=1 in the recap tracks exactly this: a task genuinely excluded by its when: condition, a third outcome distinct from both success and failure.

block, rescue, and always: real error recovery

# block_playbook.yml
---
- name: Block with rescue
  hosts: local
  tasks:
    - name: Attempt risky operation
      block:
        - name: This will fail
          command: /bin/false
        - name: This never runs
          debug:
            msg: "unreachable"
      rescue:
        - name: Handle the failure
          debug:
            msg: "Recovered from failure"
      always:
        - name: Always runs
          debug:
            msg: "Cleanup step"
ansible-playbook -i inventory.ini block_playbook.yml
TASK [This will fail] **********************************************************
fatal: [localhost]: FAILED! => {"changed": true, "cmd": ["/bin/false"], "rc": 1, ...}

TASK [Handle the failure] ******************************************************
ok: [localhost] => {
    "msg": "Recovered from failure"
}

TASK [Always runs] *************************************************************
ok: [localhost] => {
    "msg": "Cleanup step"
}

PLAY RECAP *********************************************************************
localhost                  : ok=3    changed=0    unreachable=0    failed=0    skipped=0    rescued=1    ignored=0

/bin/false genuinely fails (rc: 1) — and This never runs really doesn’t; a failure inside block: immediately jumps to rescue:, skipping any remaining tasks in the block. rescue: catches it and the play continues; always: runs regardless, the same guarantee finally gives in most languages. The recap’s failed=0 confirms the play didn’t actually fail overall — rescued=1 is the honest record of what happened instead.

Practice

1. Count the real number of changed items in a loop by inspecting the task result, not the recap.
rm -rf /tmp/loopdirs
ansible-playbook -i inventory.ini loop_playbook.yml -v 2>&1 | grep -c "changed: \[localhost\] =>"
3

-v (verbose) prints the same per-item lines seen in the main run; counting them directly is the actual way to answer “how many items changed,” since the recap’s single number can’t express that. The rm -rf matters here: the directories already exist from the main lesson above, and a converged file task reports ok, not changed — run this against already-converged state and the count comes back 0, not 3.

2. Add a when: condition inside a loop, skipping specific items rather than the whole task.
- name: Create dirs except beta
  file:
    path: "/tmp/loopdirs2/{{ item }}"
    state: directory
  loop:
    - alpha
    - beta
    - gamma
  when: item != "beta"
ansible-playbook -i inventory.ini loop_when_playbook.yml
TASK [Create dirs except beta] *************************************************
changed: [localhost] => (item=alpha)
skipping: [localhost] => (item=beta)
changed: [localhost] => (item=gamma)

when: evaluates per-iteration when paired with loop: — item refers to the current iteration’s value, so one task can selectively skip individual items instead of needing a separate task per exception.

3. Remove the rescue: section and observe the play actually fail.
- name: Attempt risky operation
  block:
    - name: This will fail
      command: /bin/false
ansible-playbook -i inventory.ini block_playbook.yml
TASK [This will fail] **********************************************************
fatal: [localhost]: FAILED! => {"changed": true, "cmd": ["/bin/false"], "rc": 1, ...}

PLAY RECAP *********************************************************************
localhost                  : ok=1    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0

Without rescue: to catch it, the same failure now shows up as failed=1 and the play stops for that host — confirming rescue: was the only thing standing between this failure and a genuinely failed play.

Next: integrating Ansible into a CI/CD pipeline.

Frequently Asked Questions

Why did looping over 3 items show changed=1 in the PLAY RECAP instead of 3?
The per-item changed: [localhost] => (item=...) lines in the task output are real, individual results — but the PLAY RECAP summarizes at the host level, not the item level. changed=1 means "this one host had at least one changed result this play," not "one thing changed." Reading recap numbers as a count of individual changes is a common misreading of Ansible's own summary line.
What does skipping: [localhost] actually mean?
The task's when: condition evaluated to false for that host, so Ansible never attempted the task at all — not a failure, not a no-op execution, genuinely skipped. skipped=1 in the recap tracks exactly this: tasks whose condition excluded them, distinct from ok (ran, no change) and changed (ran, did something).
What's the difference between rescued and failed in the PLAY RECAP?
failed counts tasks that errored and were never caught by a block's rescue — these actually stop the play (or the host) by default. rescued counts tasks that failed inside a block but were caught by that block's rescue: section, so the play continued. A play can finish successfully overall (failed=0) while still reporting rescued=1, which is exactly what happened here.
Does the always: section run even if rescue: itself fails?
Yes — always: is guaranteed to run after block: and (if triggered) rescue:, regardless of whether either of them succeeded, failed, or was skipped. It's designed for cleanup that has to happen no matter what, the same role finally has in exception handling in most general-purpose languages.
Can a when: condition reference a variable that a loop is currently iterating over?
Yes — when: is evaluated per-iteration when combined with loop:, so a condition can reference item to skip specific iterations rather than the whole task. This is how a single task handles "do this for every item, except the ones matching some condition" without needing a separate task per case.