skip to content

Playbooks and Tasks

The unit of work: plays targeting host patterns, tasks calling modules, handlers that fire only on change, and the when/loop/register controls around them. Interviewers ask about handlers and changed_when because they are how a playbook stays genuinely idempotent.

on this pageshow

questions

6

Why does an Ansible task using the command or shell module report "changed" on every run, and what do you use to make it report honestly?

level: middleimportance: must knowfreq 68%

answer

  1. Ansible cannot see inside a command
  2. safe default is always changed
  3. report versus actually running
  4. changed_when false for read-only queries
  5. creates skips the command entirely

basics

~20 s

Ansible cannot inspect what an arbitrary command did, so command and shell report changed whenever they run. Fix it with changed_when, evaluating the return code or output, with changed_when false for read-only commands, or with creates so the task is skipped entirely.

solid answer

~40 s

Modules like `ansible.builtin.file` or `ansible.builtin.package` inspect the system, decide whether anything needs doing, and return `changed` accordingly. `command` and `shell` run an opaque program — Ansible has no idea whether `/usr/local/bin/deploy.sh` mutated anything — so they report `changed` on every execution. That single dishonest result breaks the run report and, worse, fires every handler notified by the task. Three fixes: `changed_when: false` for a purely read-only command; `changed_when: "'updated' in result.stdout"` (with `register: result`) when the command's own output or `rc` tells you whether it did work; or the module's `creates` / `removes` arguments, which make Ansible skip the command entirely when the target path already exists. `creates` is the strongest because the command is not run at all, so it is genuinely idempotent rather than merely honest about its result.

code

yaml · 14 lines
yaml
- name: Read cluster status (read-only, never a change)
  ansible.builtin.command: /usr/local/bin/cluster-ctl status
  register: cluster
  changed_when: false

- name: Apply migrations (changed only when it did work)
  ansible.builtin.command: /usr/local/bin/migrate --apply
  register: migrate
  changed_when: "'no pending migrations' not in migrate.stdout"

- name: Unpack the bundle exactly once
  ansible.builtin.command: /usr/bin/unpack-vendor.sh /opt/vendor
  args:
    creates: /opt/vendor/.unpacked

go deeper

for a junior

Know that command and shell always report changed, and that read-only commands should carry changed_when: false. Be able to say why a purpose-built module is preferred over shelling out.

for a middle

Explain the mechanism: Ansible cannot inspect an arbitrary program, so it defaults to changed, and changed_when, failed_when and the creates/removes arguments are how you restore an honest status.

for a senior

Connect the false status to production consequences — every handler notified by such a task fires on every run — and choose between rewriting the report and making the side effect itself conditional with creates.

for a principal

Own the standard: how a team keeps shell-outs rare and reviewable, whether a non-zero changed count in a steady-state run is treated as a build failure, and what that convention buys in drift visibility across the fleet.

## The idempotency contract Every Ansible task returns a result status: `ok`, `changed`, `skipped`, `failed`, or unreachable. The status is not cosmetic. It drives the play recap, it drives handler notification, and it is the basis of the claim that a playbook can be run repeatedly with no further effect. Most modules honour that contract by checking current state first: `ansible.builtin.file` stats the path, `ansible.builtin.service` queries the service manager, `ansible.builtin.copy` compares checksums. If reality already matches, the module returns `ok` and touches nothing. ## Why command and shell cannot `ansible.builtin.command` and `ansible.builtin.shell` run an arbitrary program. Ansible has no model of what that program does. It cannot tell that `mysql -e 'CREATE DATABASE IF NOT EXISTS app'` was a no-op, or that a deploy script exited early. Faced with no information, the modules take the safe default and report **changed** every time they execute. (`ansible.builtin.raw` behaves the same way.) The visible symptoms in a real repository: - The play recap shows `changed=7` on a run where nothing happened, so the recap stops being a signal that anything is wrong. - A handler notified by such a task restarts a production service on every single run. - Check mode is useless for that task: by default `command` is simply skipped under `--check`, so you get no preview at all. ## Fix 1: changed_when `changed_when` overrides how the task's changed status is computed. It takes a raw Jinja expression, exactly like `when`. ```yaml - name: Read the current cluster leader ansible.builtin.command: /usr/local/bin/cluster-ctl leader register: leader changed_when: false ``` A read-only query gets `changed_when: false` — the command runs, but it never claims a change and never fires a handler. When the command may or may not do work, derive the answer from what it told you: ```yaml - name: Apply pending migrations ansible.builtin.command: /usr/local/bin/migrate --apply register: migrate changed_when: "'no pending migrations' not in migrate.stdout" ``` A return-code convention works the same way: `changed_when: migrate.rc == 2`. Note that `changed_when` only rewrites the *report*; the command still ran. It also pairs with `failed_when`, which overrides failure the same way — a command that exits non-zero for an expected condition needs `failed_when` so it is not treated as an error. ## Fix 2: creates and removes `creates` and `removes` are arguments of the `command` and `shell` modules themselves, not general task keywords. If the path in `creates` already exists, the module does not run the command at all and the task reports **skipped**; `removes` is the mirror image — run only if the path exists. ```yaml - name: Unpack the vendor bundle once ansible.builtin.command: /usr/bin/unpack-vendor.sh /opt/vendor args: creates: /opt/vendor/.unpacked ``` This is stronger than `changed_when`, because the side effect itself is now conditional rather than just the label on it. Where the command has a natural marker file or produced artefact, prefer it. ## Fix 3: do not use command at all The best fix is often to delete the task. `command: systemctl restart nginx` should be the `service` module; `shell: 'echo x >> /etc/hosts'` should be `lineinfile`; a `curl` should be `uri`. Reaching for `shell` when a purpose-built module exists is the single most common reason a playbook is not idempotent, and interviewers ask this question specifically to see whether you reach for the module first. ## When shell instead of command `command` does not run through a shell: no pipes, no redirection, no globbing, no environment-variable expansion. `shell` does. Use `command` unless you genuinely need shell features, because passing untrusted variables into `shell` is a command-injection surface. If you must, quote with the `quote` filter. ## The judgment an interviewer is listening for A weak answer says "add changed_when: false" and stops — that hides the noise and can hide a real change. A strong answer separates three cases: the command reads (report false), the command may act and tells you (derive from stdout or rc), the command acts once (`creates`, or move the conditional into the task). And it names the consequence that makes this more than cosmetics: a mis-reported change is what turns a config-management run into an unwanted production restart.

  • What is the difference between changed_when: false and using the creates argument?
    `changed_when: false` still runs the command every time and only rewrites the reported status — appropriate for read-only queries. `creates` prevents the command from running at all when the named path exists, so the task reports skipped and the side effect genuinely happens once. Use `creates` when the command mutates something and has a natural marker; use `changed_when: false` when it only reads.
  • A command exits with return code 1 for a condition you consider normal. How do you keep the play going without hiding real errors?
    Set `failed_when` to the condition that is genuinely a failure — for example `failed_when: result.rc not in [0, 1]`, with `register: result`. That is narrower than `ignore_errors: true`, which swallows every failure including the ones you did want to hear about, and it keeps the task's reported status meaningful.
  • Why is command generally preferred over shell?
    `command` does not invoke a shell, so pipes, redirection, globbing and variable expansion are not available — and neither is shell injection through an interpolated variable. `shell` should be reserved for cases that actually need those features; interpolating untrusted input into it is a real command-injection risk.

saying these in an interview costs you the question

  • Claims command is idempotent because the script is idempotent
  • Puts changed_when: false on every command task to silence the recap
  • Thinks creates makes the task report changed rather than skipped
  • Uses ignore_errors where failed_when would express the real condition
  • Reaches for shell when a purpose-built module exists

context

open as a page

In an Ansible playbook, when do handlers actually run, and what has to happen for a handler to fire at all?

level: middleimportance: must knowfreq 72%

basics

~20 s

A handler runs only when a task that notifies it reports changed, and it runs once, at the end of the play, not at the point of notification. A meta flush_handlers task forces queued handlers to run earlier.

open as a page

In an Ansible task, what does the when keyword do, and why is its expression written without the {{ }} delimiters?

level: juniorimportance: should knowfreq 66%

basics

~20 s

The when keyword makes a task run only if its expression is true; otherwise the task is skipped for that host. The value is already evaluated as a Jinja expression, so wrapping it in curly braces is redundant and Ansible warns about it.

open as a page

In an Ansible play, how do you let a task fail without aborting the run, and what does block/rescue/always give you that ignore_errors does not?

level: seniorimportance: should knowfreq 44%

basics

~20 s

ignore_errors continues past a failed task but still marks it failed and offers no recovery path. block/rescue/always groups tasks, runs the rescue section when any task in the block fails, and always runs cleanup either way, so the host is marked recovered rather than failed.

open as a page

You must apply a config change across 60 web servers with Ansible without taking the whole fleet out at once. Which play-level keywords control that, and how do handlers behave under them?

level: seniorimportance: should knowfreq 46%

basics

~20 s

Set serial on the play to run it in batches of hosts instead of all at once, optionally with max_fail_percentage to abort if a batch goes badly. Because the whole play repeats per batch, handlers flush at the end of each batch, so restarts roll host group by host group.

open as a page

When an Ansible task has both loop and register, what does the registered variable contain, and what is loop_control's label used for?

level: middleimportance: nice to knowfreq 40%

basics

~20 s

With a loop, the registered variable holds a results list with one entry per iteration, each carrying its own item and module return values, rather than a single result. The loop_control label sets what Ansible prints for each iteration instead of the whole item.

open as a page