Skip to content

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:

deploy_all_configs.yaml
- 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 pipe as 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 into loop:.

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 quote solve two genuinely different problems, and a real secret-handling pattern needs both.
  • Default to query() for anything feeding loop:, and keep plain lookup() 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.
Last updated on