Skip to main content
RunBook Academy

AnsibleXXVIII · Plugins, Lookups and FiltersPlugins, lookups and filters

Lookups execute on the controller

Intermediate⏱ ~20 minansible-playbook

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

Not yet marked complete on this device.

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.

Read-only / Safeinv.ini
[web]
web01.example.com ansible_host=192.0.2.11
web02.example.com ansible_host=192.0.2.12

A file on the controller, containing a string that identifies it:

Read-only / Safemotd.txt, on the controller
controller-copy

And 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:

Read-only / Safeproof.yml
- 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 }}"
Read-only / Saferun it
$ ansible-playbook -i inv.ini proof.yml
TASK [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=0

Read those two task results together, because the pair is the argument:

  • The lookup task reported ok for both hosts and returned controller-copy — the content of a file on the machine running ansible-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 readOn the controllerOn the target
A file’s contentslookup('ansible.builtin.file', path)ansible.builtin.slurp + b64decode, or ansible.builtin.fetch
A file’s metadatalookup('ansible.builtin.fileglob', pattern)ansible.builtin.stat
Command outputlookup('ansible.builtin.pipe', cmd)ansible.builtin.command + register
An environment variablelookup('ansible.builtin.env', name)ansible_env fact, after fact gathering
Lines of a filelookup('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.

Read-only / Safethe remote read, written correctly
- 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:

Read-only / Safetemplated argument: still a controller-side read
# 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

  1. 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?

  2. Q2. A play uses copy with content: "{{ lookup('file', '/opt/app/config.json') }}" intending to read each host's existing config. What happens?

  3. Q3. Which of these read data from the managed node rather than the controller? Select all that apply.

  4. 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.