AnsibleLII · Anti-PatternsAnti-patterns of reporting
Anti-pattern: encoding the inventory in when
What you'll learn
- Recognise inventory logic that has migrated into playbook conditionals
- Explain why the migration destroys reviewability and makes check mode misleading
- Move the logic back into groups, group variables and role defaults
- Recognise the three related structural failures: no roles, trivial roles, and -e overriding everything
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
Ansible gives you two places to express “which hosts get what”: the inventory, and conditionals in the play. The inventory is declarative, inspectable and reviewable. Conditionals are none of those, and they are always closer to hand.
So the logic migrates. One when: at a time, each perfectly reasonable
in isolation.
What it looks like
- name: Configure the application
hosts: all
tasks:
- name: Set the worker count
ansible.builtin.template:
src: app.conf.j2
dest: /etc/app/app.conf
vars:
workers: "{{ 16 if inventory_hostname is match('web(0[1-9]|1[0-2])\\..*')
else 4 }}"
when:
- inventory_hostname is match('^(web|api)')
- inventory_hostname not in ['web07.example.com', 'api03.example.com']
- ansible_facts['distribution_major_version'] | int >= 9
- not (inventory_hostname is match('.*-canary$') and skip_canary | default(false))
- name: Apply the legacy tuning
ansible.builtin.template:
src: legacy-tuning.conf.j2
dest: /etc/sysctl.d/99-app.conf
when: inventory_hostname in ['web03.example.com', 'web04.example.com',
'db01.example.com']
notify: Restart appEvery line arrived for a reason. web07 had an incident. Version 8
hosts need different handling. The canary needed an escape hatch during
a rollout that finished eighteen months ago.
The failure: nobody can answer the targeting question
The question this course opens with — which hosts does this change touch? — is unanswerable here without running the play.
--list-hosts does not help. It reports the play’s host pattern,
which is all. It knows nothing about task-level conditionals.
--check on one host proves nothing about the others. The
conditional depends on the hostname and on facts, so a successful check
against web01 tells you nothing about web07, api03 or the
version-8 hosts. Verifying the change means check-running the whole
fleet and reading three hundred results.
Review is not possible. A reviewer would have to evaluate four conditions against three hundred hostnames and their facts, in their head. They will approve it instead.
The exclusion list is unmaintainable. web07 is excluded. Why? The
commit message says “fix web07”. The person who wrote it has left. Is
the reason still true? Nobody will ever remove it, and it will silently
stop applying when web07 is rebuilt with a different name.
The corrected form
Move the logic into the inventory, where it is data.
all:
children:
app_tier:
children:
web:
hosts:
web[01:12].example.com:
api:
hosts:
api[01:04].example.com:
high_capacity:
hosts:
web[01:12].example.com:
legacy_tuning:
hosts:
web03.example.com:
web04.example.com:
db01.example.com:
excluded_from_app_config:
# web07: hardware fault under investigation, ticket OPS-4412,
# review 2026-09-01. api03: pending decommission.
hosts:
web07.example.com:
api03.example.com:# group_vars/app_tier.yml
workers: 4
# group_vars/high_capacity.yml
workers: 16
# site.yml
- name: Configure the application
hosts: 'app_tier:!excluded_from_app_config'
tasks:
- name: Set the worker count
ansible.builtin.template:
src: app.conf.j2
dest: /etc/app/app.conf
- name: Apply the legacy tuning
hosts: legacy_tuning
tasks:
- name: Install the sysctl drop-in
ansible.builtin.template:
src: legacy-tuning.conf.j2
dest: /etc/sysctl.d/99-app.conf
notify: Restart appNow the question has an answer, before anything runs:
# Which hosts are in each group?
ansible-inventory -i inventory/ --graph
# Exactly who does this play target, with the exclusion applied?
ansible-playbook site.yml --list-hosts
# What does a specific host resolve to?
ansible-inventory -i inventory/ --host web03.example.comThe exclusion is now a group with a comment carrying a ticket number and a review date. It is still an exception — but it is a documented, greppable, reviewable one, which is the whole difference.
Three structural failures that travel with it
One monolithic playbook, no roles
A 900-line site.yml with no roles has the same reviewability problem
at a different scale: no unit of the automation has a name, an
interface, defaults or a boundary, so nothing can be reasoned about,
tested or reused in isolation. Part XXII covers the refactor.
Hundreds of trivial roles
The overcorrection, and it is genuinely worse in one respect. A role per
task — install-nginx, start-nginx, configure-nginx — gives you all
the ceremony of roles with none of the encapsulation, plus a dependency
graph nobody can hold in their head and a requirements.yml with two
hundred entries.
The heuristic that separates them: a role should own a service or a
concern, and should be usable on its own. nginx is a role.
start-nginx is a task.
-e overriding everything
Extra-vars sit at the top of the precedence order and cannot be
overridden by anything. That makes -e the universal fix for a variable
that resolved wrongly — and the reason nobody can predict what a value
will be.
ansible-playbook site.yml -e workers=32 -e app_version=2.1.0 -e skip_canary=true -e max_hosts=9999The failure. The run happened with values that exist only in one person’s shell history. The repository does not describe what was deployed. A rerun without the same flags produces a different result, which means the run is not reproducible — and when somebody reruns it to recover from an incident, they get the un-overridden values.
The corrected form. Put the value where its precedence layer belongs:
a group variable if it is a property of those hosts, a play vars: if it
is a property of that play, a role default if it is an interface. Reserve
-e for genuinely per-invocation values — a version being deployed, an
acknowledgement flag — and pass them from a file that is committed:
# releases/2026-08-11-app-2.1.0.yml holds the values, and is committed.
ansible-playbook site.yml --limit app_prod -e @releases/2026-08-11-app-2.1.0.ymlPart XIII covers precedence in full, including the incident lesson on what happens when it is misused.
Knowledge check
Knowledge check · 4 questions
Q1. A play uses `hosts: all` with four task-level conditions including hostname regexes and an exclusion list. Why does `--check` on one host fail to validate the change?
Q2. Which properties does moving targeting logic from conditionals into inventory groups restore? Select all that apply.
Q3. Any `when:` condition in a playbook is an instance of this anti-pattern and should be replaced by inventory groups.
Q4. A deployment is run with `-e workers=32 -e app_version=2.1.0 -e skip_canary=true`. What is the specific problem?
Passing score: 75%. Answers are checked in this browser.