AnsibleXXVIII · Plugins, Lookups and FiltersPlugins, lookups and filters
Lookups execute on the controller
What you'll learn
- State without hedging where a lookup executes, and prove it
- Choose between a lookup and the remote equivalent for a given requirement
- Recognise the failure signature of a lookup mistaken for a remote read
- Review an expression for controller-versus-target confusion
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
lookup('file', '/etc/passwd') reads the controller’s
/etc/passwd. Not the managed node’s. Every time, for every host in
the play, regardless of what the play targets.
Part VIII said this in passing while explaining action plugins. It is repeated here, in a lesson of its own, because saying it does not work. People read it, agree with it, and then write a play that assumes the opposite six months later — because the expression sits inside a task that is targeting a remote host, and everything about how it is written suggests it happens there.
So this lesson does not assert it. It proves it.
The demonstration
An inventory of two hosts in the documentation range 192.0.2.0/24.
These addresses are reserved for documentation and are not routable —
nothing answers on them, by design.
[web]
web01.example.com ansible_host=192.0.2.11
web02.example.com ansible_host=192.0.2.12A file on the controller, containing a string that identifies it:
controller-copyAnd a play with two tasks. The first uses a lookup. The second uses
slurp, which is the module for reading a file from a managed node:
- name: Prove the hosts are unreachable
hosts: web
gather_facts: false
tasks:
- name: A lookup, which needs no connection
ansible.builtin.debug:
msg: "{{ lookup('ansible.builtin.file', 'motd.txt') }}"
- name: A slurp, which does
ansible.builtin.slurp:
src: /etc/motd
register: remote_motd
- name: What the target actually had
ansible.builtin.debug:
msg: "{{ remote_motd.content | b64decode }}"$ ansible-playbook -i inv.ini proof.ymlTASK [A lookup, which needs no connection] *************************************
ok: [web01.example.com] => {
"msg": "controller-copy"
}
ok: [web02.example.com] => {
"msg": "controller-copy"
}
TASK [A slurp, which does] *****************************************************
fatal: [web01.example.com]: UNREACHABLE! => {"changed": false, "msg": "Task failed: Failed to connect to the host via ssh: ssh: connect to host 192.0.2.11 port 22: Connection timed out", "unreachable": true}
fatal: [web02.example.com]: UNREACHABLE! => {"changed": false, "msg": "Task failed: Failed to connect to the host via ssh: ssh: connect to host 192.0.2.12 port 22: Connection timed out", "unreachable": true}
PLAY RECAP *********************************************************************
web01.example.com : ok=1 changed=0 unreachable=1 failed=0 skipped=0 rescued=0 ignored=0
web02.example.com : ok=1 changed=0 unreachable=1 failed=0 skipped=0 rescued=0 ignored=0Read those two task results together, because the pair is the argument:
- The lookup task reported
okfor both hosts and returnedcontroller-copy— the content of a file on the machine runningansible-playbook. - The very next task, against the very same hosts, could not open a TCP connection to either of them.
The hosts do not exist. There is no SSH daemon, no route, nothing. And the lookup still produced data, because it never tried to go there.
Note the second detail, which matters as much: both hosts returned the identical string. A lookup does not vary per host, because the per-host thing — the host — is not involved.
Which one do you need?
| You want to read | On the controller | On the target |
|---|---|---|
| A file’s contents | lookup('ansible.builtin.file', path) | ansible.builtin.slurp + b64decode, or ansible.builtin.fetch |
| A file’s metadata | lookup('ansible.builtin.fileglob', pattern) | ansible.builtin.stat |
| Command output | lookup('ansible.builtin.pipe', cmd) | ansible.builtin.command + register |
| An environment variable | lookup('ansible.builtin.env', name) | ansible_env fact, after fact gathering |
| Lines of a file | lookup('ansible.builtin.lines', cmd) | slurp then split, or command |
The remote form is always a module plus register, and that is the
tell. A module executes on the target; a lookup does not. If your
expression has no module in it, it did not go anywhere.
- name: Read the config from the target
ansible.builtin.slurp:
src: /opt/app/config.json
register: target_config
- name: Use it
ansible.builtin.debug:
msg: "{{ target_config.content | b64decode | from_json }}"slurp returns base64 — always, for every file, because it has to
survive JSON transport intact — so b64decode is not optional. That
mandatory decode step is a useful marker in review: an expression
with b64decode in it read the target; one without it did not.
Reviewing for this
Two questions on any expression containing a lookup:
1. Where does this data live? Repository, controller environment, controller-reachable service — a lookup is right. On the managed node — you need a module.
2. Would identical output on every host be correct? A lookup produces the same value for every host in the play unless one of its arguments is templated from a host variable. If the requirement is per-host data, the expression is wrong.
The second question catches this one, which is subtler because it looks per-host:
# The path varies per host. The read does not: this opens
# /opt/configs/web01.example.com.json ON THE CONTROLLER.
- ansible.builtin.debug:
msg: "{{ lookup('ansible.builtin.file', '/opt/configs/' + inventory_hostname + '.json') }}"The argument is templated per host, so the output differs per host, so it looks like a remote read. It is not. It reads a controller-directory of per-host files — which is a perfectly good pattern when that is what you meant, and a confusing bug when it is not.
Knowledge check
Knowledge check · 4 questions
Q1. A play targets two hosts in 192.0.2.0/24 that nothing answers on. A debug task using lookup('file', 'motd.txt') reports ok for both, and the next task using slurp reports UNREACHABLE. What does the pair establish?
Q2. A play uses copy with content: "{{ lookup('file', '/opt/app/config.json') }}" intending to read each host's existing config. What happens?
Q3. Which of these read data from the managed node rather than the controller? Select all that apply.
Q4. Templating a lookup argument with inventory_hostname makes the lookup read the corresponding managed node.
Passing score: 75%. Answers are checked in this browser.