Lookup Patterns
This closing chapter combines everything from this section — file, env, pipe, fileglob, query() — with tools from across the entire course, into patterns that actually resemble what a real playbook looks like. It ends with the other half of the picture: knowing exactly when reaching for a lookup is the wrong instinct.
Pattern: Bulk-Copying Local Config Files, With Failure Handling
fileglob/query() and loop:, combined with block/rescue and handlers from that section, and inventory_hostname from the variables section:
- name: Deploy All Local Configs
hosts: servers
tasks:
- name: Copy every local config file
block:
- name: "Copy configs"
ansible.builtin.copy:
src: "{{ item }}"
dest: "/etc/app/{{ item | basename }}"
loop: "{{ query('fileglob', 'configs/*.conf') }}"
notify: Restart app
loop_control:
label: "{{ item | basename }}"
rescue:
- name: Alert on any copy failure
ansible.builtin.debug:
msg: "Config deployment failed on {{ inventory_hostname }}"
changed_when: true
notify: Send alert
handlers:
- name: Restart app
ansible.builtin.debug:
msg: "Restarting app after config update"
- name: Send alert
ansible.builtin.debug:
msg: "ALERT: config deployment failed on {{ inventory_hostname }}"Every config file found locally gets pushed out and triggers a restart; any failure along the way is caught and alerted on separately, instead of the whole play dying silently.
Pattern: Validating And Safely Using A Control-Node Secret
Emptiness check with quote discipline:
vars:
db_password: "{{ lookup('env', 'DB_PASSWORD') }}"
tasks:
- name: Fail clearly if the secret is missing
ansible.builtin.fail:
msg: "DB_PASSWORD is not set on the control node"
when: db_password | length == 0
- name: Safely use the secret in a local command
ansible.builtin.debug:
msg: "{{ lookup('pipe', 'echo Connecting with password ' + (db_password | quote)) }}"Pattern: Embedding Build Metadata From Multiple Local Sources
pipe and file together, describing a deployment in one message:
vars:
git_commit: "{{ lookup('pipe', 'git rev-parse --short HEAD') }}"
changelog_first_line: "{{ lookup('file', 'CHANGELOG.md').split('\n')[0] }}"
tasks:
- name: Announce deployment
ansible.builtin.debug:
msg: "Deploying commit {{ git_commit }} — {{ changelog_first_line }}".split('\n')[0] here is calling a plain Python string method directly — Jinja2 allows this on any value, not just its own filters, which is worth knowing as an escape hatch for anything a dedicated filter doesn’t already cover.
Pattern: Optional Per-Host Override Files
A genuinely common real scenario: most hosts use defaults, but a specific host occasionally needs its own override file, and the playbook shouldn’t fail if that file simply doesn’t exist for a given host:
vars:
override_content: "{{ lookup('file', 'overrides/' + inventory_hostname + '.yaml', errors='ignore') }}"
tasks:
- name: Report override status
ansible.builtin.debug:
msg: "{{ 'Override found for ' + inventory_hostname if override_content | length > 0 else 'No override for ' + inventory_hostname + ', using defaults' }}"Two small new details here. errors='ignore', passed to the file lookup, suppresses the error that would normally happen when the file doesn’t exist, returning an empty result instead — exactly what’s needed for something genuinely optional. And {{ 'a' if condition else 'b' }} is Jinja2’s compact inline conditional — a whole if/else in one expression, useful for exactly this kind of short branching message without needing a separate when:-gated task for each outcome.
A Combined Danger: Silent Emptiness Meets Unquoted Injection
This is worth seeing as one scenario, since Chapters 3 and 4’s gotchas compound when combined carelessly:
vars:
db_password: "{{ lookup('env', 'DB_PASSWORD') }}"
tasks:
- name: DANGEROUS — no validation, no quoting
ansible.builtin.debug:
msg: "{{ lookup('pipe', 'mysql -u root -p' + db_password + ' -e \"SELECT 1\"') }}"If DB_PASSWORD was never actually set on the control node, db_password is silently an empty string (Chapter 3’s gotcha) — and the resulting command becomes mysql -u root -p -e "SELECT 1", which behaves completely differently than intended (many MySQL clients treat a bare -p as “prompt for a password interactively,” which would hang a non-interactive playbook run entirely). And if the password genuinely were set but happened to contain a shell-special character, Chapter 4’s injection risk applies on top of that. Two separate silent failure modes, compounding into one confusing hang or failure.
The fix applies both chapters’ lessons together:
- name: Validate the secret exists
ansible.builtin.fail:
msg: "DB_PASSWORD is not set on the control node"
when: db_password | length == 0
- name: Safely use the secret
ansible.builtin.debug:
msg: "{{ lookup('pipe', 'mysql -u root -p' + (db_password | quote) + ' -e ' + ('SELECT 1' | quote)) }}"Validated for existence, and quoted before being embedded — both problems closed, not just one.
When Lookups Aren’t The Right Tool
- Don’t reach for a lookup to fetch data you already have. If it’s already a fact, a registered result, or an existing variable, a lookup is solving a problem you don’t have — check what’s already available before reaching for
lookup()out of habit. - Don’t expect
file(or any lookup) to read something from the remote host. Every lookup in this section runs on the control node, always — reading a remote file requires an actual task running there, not a lookup. - Don’t reach for
pipeas a substitute for a proper module when a dedicated one already exists for what you’re trying to do — a purpose-built module is almost always more idempotent, more portable, and safer than shelling out to replicate its behavior manually. - Don’t forget
wantlist=true/query()the moment a lookup might return more than one result and you’re feeding it intoloop:.
What You Just Did
Every pattern in this chapter reached across the whole course: file, env, pipe, fileglob, and query() from this section; quote from the manipulating-data section; register, is failed, and fail from tests and conditionals; loop and basename; block, rescue, notify, and handlers; and inventory_hostname from the variables section, right at the start of this course. Nothing here required new mechanics beyond this section’s own five lookup-specific chapters — the rest was simply everything else you already know, applied together.
Best Practices
- Check whether the data you need already exists as a fact, variable, or registered result before reaching for a lookup — this is the single most common wasted effort in this whole area.
- Validate and quote control-node secrets together, not just one or the other — Chapter 3’s emptiness check and Chapter 4’s
quotesolve two genuinely different problems, and a real secret-handling pattern needs both. - Default to
query()for anything feedingloop:, and keep plainlookup()for the naturally single-valued plugins. - Use
errors='ignore'for genuinely optional local files, rather than letting a missing-but-expected file crash the whole play.