Lookups
A lookup pulls data from outside the playbook at the control node, before anything ships to the managed node. {{ lookup('file', 'tls/larkspur.pem') }} reads a file on your laptop, {{ lookup('env', 'HOME') }} reads the control node's environment, {{ lookup('password', '...') }} generates a password and stores it locally. They are how a render reaches data that lives where Ansible runs.
The single fact that trips everyone: lookups run where Ansible runs, not where the task lands. lookup('file', '/etc/hostname') reads your laptop's /etc/hostname, never the server's. That distinction is not a quirk — it is the agentless, push model showing through in the templating layer. Rendering happens on the control node, so anything a lookup reads is the control node's, full stop.
lookup(...)lookup('file', ...) is your laptop's copy, never the server's.ansible_hostname describes the target itself; to read a remote file, slurp it or register a command.Lookups Run on the Control Node
Every lookup evaluates locally during rendering, so the files, environment variables, and secret stores it reads are the control node's, not the target's. This is the same control-versus-managed split that runs through the whole book — Ansible has no agent on the server, so anything resolved before the file ships is resolved where Ansible itself runs. A lookup is local data, period.
The corollary matters as much as the rule: to read a file that lives on the managed node, a lookup is the wrong tool. You use the slurp module or a registered command task, both of which execute on the target and bring the result back. Reaching for lookup('file', ...) when you mean "the server's copy" is the single most common lookup bug, and it fails silently by returning your laptop's data instead.
The Core Lookups
Five cover most of what you need. file reads a control-node file into a variable; env reads a control-node environment variable; password generates a password and caches it to a local file; template renders a .j2 to a string instead of writing a file; and pipe runs a local command and captures its output. Each one reads or runs on the control node, which is the thread tying them together.
# read a control-node file into a variable tls_cert: "{{ lookup('file', 'tls/larkspur.pem') }}" # read a control-node environment variable home_dir: "{{ lookup('env', 'HOME') }}" # generate once and cache to a LOCAL file — not regenerated each run session_secret: "{{ lookup('password', 'creds/larkspur_session length=48') }}"
The password lookup is the one with a surprise: it does not regenerate each run. It generates a value the first time and caches it to the local file you name, so the password is stable across runs — which is what you want for a session secret that must not change every deploy. The catch is that the value then lives in plaintext on the control node unless you point it at a Vault-managed path.
first_found
lookup('first_found', params) returns the first path that exists from a candidate list — the standard way to pick an OS-specific or environment-specific file. Try vars/{{ ansible_distribution }}.yml, fall back to vars/default.yml, and the lookup resolves to whichever exists first, all on the control node. It replaces a chain of {% if %} branches with one declarative selection.
The pattern shows up constantly: a role that needs a different config snippet on Debian versus RHEL, or a different secrets file in staging versus prod, names the candidates in priority order and lets first_found pick. Because the resolution is control-node file existence, it is fast and predictable, and it keeps the selection logic in one place instead of scattered across conditionals.
query vs lookup
lookup() returns a comma-joined string by default — a flattening that bites the moment you wanted a list. query() always returns a real list. The rule is mechanical: use query() (or lookup(..., wantlist=true)) anywhere the result feeds a loop:, and reserve plain lookup() for the cases where you genuinely want one string.
The failure is quiet, which is what makes it nasty. Feed a lookup() into a loop: and you do not get an error — you get a loop that iterates exactly once, over the whole comma-joined blob as a single item. The fix is to switch the call to query() so the loop sees the individual elements. When a lookup feeds a loop, it should almost always be query().
Where Lookups Fit the Larkspur Stack
Each Larkspur lookup is local data feeding a render that then ships out. The TLS cert and key are read off the control node into larkspur.conf with lookup('file', ...); the app's session secret is generated once with password and cached locally so it survives redeploys; the per-distro config snippet is chosen with first_found. None of these touch the managed node directly — they assemble the file on the control node, and the template module sends the finished result. The whole topic is one rule applied three ways: read where Ansible runs, then ship.
Lookup — control node. A lookup like lookup('file', '/etc/hostname') reads the control node, every time, because it runs during local rendering. The files, environment variables, and secret stores it touches are the machine running Ansible. Reach for a lookup only when the data genuinely lives where Ansible runs.
Fact or remote read — managed node. A fact like ansible_hostname, or a slurp/command task, reads the managed node, because it executes there. Confusing the two means a lookup silently returns your laptop's data when you meant the server's — so to read a remote file, slurp it or register a command, never a lookup.
- Using
lookup('file', '/etc/some.conf')expecting the managed node's copy — it reads the control node's file every time; to read a remote file,slurpit or register acommand/cat, because lookups never touch the target. - Using
lookup()in aloop:and getting one comma-joined string instead of a list, so the loop iterates once over the whole blob — usequery()orwantlist=truewhen you loop. - Calling
lookup('file', ...)on a path that does not exist on the control node and having the play fail at render time with a confusing error — guard withfirst_foundor a default when the file is optional. - Reading a secret with
lookup('env', 'DB_PASSWORD')from whatever environment the control node happens to have, making runs non-reproducible across laptops and CI — secrets belong in Vault, not the ambient control-node environment. - Assuming
lookup('password', ...)regenerates each run — it generates once and caches to the local file, so the password is stable across runs but lives in plaintext on the control node unless you point it at a Vault-managed path.
- Treat every lookup as "read this on the machine running Ansible," and reach for
slurpor a registeredcommandthe moment the data lives on the managed node instead. - Use
query(), notlookup(), anywhere the result feeds aloop:, so you get a real list rather than a silently flattened string. - Resolve OS- or environment-specific files with
first_foundinstead of a chain of{% if %}branches, keeping the selection logic in one declarative lookup. - Keep secrets in Vault and read them as variables, rather than pulling them from the control node's environment with
lookup('env', ...), so runs are reproducible and the secret is not tied to one machine's shell. - Point
lookup('password', ...)at a Vault-managed path when the generated value is a real secret, so the cached password does not sit in plaintext on the control node.
Knowledge Check
Which machine does lookup('file', '/etc/hostname') read from?
- The control node, always — lookups evaluate during local rendering, never on the target
- The managed node, since that is where the rendered config file will eventually land
- Whichever machine the path
/etc/hostnamehappens to exist on at run time - Both — it reads the control node first and then falls back to the target if missing
You need to read a config file that lives on the managed node. What is the right tool?
- The
slurpmodule or a registeredcommandtask, both of which execute on the target lookup('file', ...)pointed at the file's absolute path on the managed serverlookup('env', ...)pointed at the remote file path on the target hostfirst_foundwith the managed node's absolute path listed as the first candidate
Why does feeding lookup() into a loop: often iterate only once?
lookup()returns a comma-joined string by default, so the loop sees one blob;query()returns a real listlookup()caches its first matched result internally, so the loop just reuses that one cached value on every pass- A loop over a lookup runs entirely on the control node, which has only the single local host available to iterate over
lookup()stops after the first matched item unless you setrecursive=trueon it
What does first_found solve?
- Picking the first path that exists from a candidate list, replacing a chain of
{% if %}branches for OS- or environment-specific files - Reading just the first line of a config file that lives on the managed node and returning that one line as the lookup's value
- Finding the first reachable host in an inventory group by pinging each in order, so a task always targets a machine that is actually up
- Returning the first variable that happens to be defined from a list of candidate names, walking them in priority order until one resolves
You got correct