skip to content

In Ansible, what is the difference between a module and a plugin, and where does each one actually execute?

level: middleimportance: should knowfreq 45%

answer

  1. which machine runs the code?
  2. payload shipped versus loaded locally
  3. lookups read the controller's filesystem
  4. slurp when you need the remote file
  5. collections hold both since 2.10

basics

~20 s

Modules are units of work shipped to the managed host and executed there by its Python interpreter, returning JSON. Plugins extend the control node itself — lookup, connection, callback, inventory, strategy, become — and run locally, never on the target.

solid answer

~50 s

A module is the thing a task dispatches: Ansible packages the module code, copies it to the managed host, runs it with the remote Python interpreter, and reads a JSON result from its stdout. That is why almost every module needs Python on the target and why modules are stateless between tasks. A plugin extends the controller's own machinery and runs on the control node: connection plugins move data to the host, lookup plugins fetch values, callback plugins render output and ship it to logging systems, inventory plugins build the host list, strategy plugins decide task ordering, become plugins build privilege-escalation commands. The distinction has a very practical consequence people get wrong: `lookup('file', '/etc/motd')` reads that file on the *control node*, not on the target. To read a remote file you need a module — `ansible.builtin.slurp`, or a `command`. Action plugins straddle both: they run on the controller and typically prepare work before invoking a module.

code

bash · 9 lines
bash
# What modules exist?
ansible-doc -l | head

# What lookup plugins exist, and what does one do?
ansible-doc -t lookup -l
ansible-doc -t lookup file

# Which connection plugin is in use for a host?
ansible -i inventory.ini web01 -m ansible.builtin.setup -a 'filter=ansible_python_interpreter'

go deeper

for a junior

Know that a task calls a module, that the module runs on the managed host, and that plugins are controller-side extensions such as lookups and callbacks. Do not confuse the two directions.

for a middle

Explain the transfer-and-execute model — payload shipped, remote Python runs it, JSON comes back — and give the lookup-versus-slurp example of why the execution location matters in practice.

for a senior

Bring the debugging angle: a remote traceback versus a controller-side one tells you which side failed, and the Python interpreter on the target is a real dependency you have to manage across fleet OS versions.

for a principal

Own the extension strategy: which custom plugins the organisation maintains, how they are packaged and versioned in collections, and the support cost of controller-side code that every playbook silently depends on.

## Two extension points, two machines Ansible has two distinct extension mechanisms and they run in different places. Getting them confused produces bugs that look like magic. **Modules** are the units of work. When a task says `ansible.builtin.file: path=/etc/app state=directory`, Ansible resolves the module, wraps its code and its dependencies into a self-contained payload (the AnsiballZ wrapper), transfers it to the managed host over the connection, executes it there with the host's Python interpreter (selectable with `ansible_python_interpreter`), captures the JSON it prints on stdout, and removes the payload. Everything the module knows about the world it must discover itself on that host. Modules are stateless between tasks — nothing carries over except what you `register`. **Plugins** extend the controller. They are loaded into the Ansible process running on your laptop or CI runner and never touch the managed host as code. The plugin types you are most likely to be asked about: - **connection** — how Ansible reaches the host: `ssh`, `local`, `docker`, `winrm`, `psrp`. - **lookup** — fetch a value at template time from the controller's world: files, environment variables, generated passwords, external stores. - **callback** — receive events during the run and do something with them: the default stdout renderer is a callback, as are JSON output, timing summaries and shipping results to a logging system. - **inventory** — build the host list, including dynamic sources. - **filter** and **test** — extend Jinja2 expressions. - **strategy** — decide the ordering of hosts and tasks (`linear`, `free`). - **become** — construct the privilege-escalation command line (`sudo`, `su`, `doas`). - **cache**, **vars**, **shell**, **action**, **terminal**, **cliconf**, **httpapi** for the rest. ## The consequence people trip over A lookup runs on the controller. So: ```yaml # Reads /etc/motd on the CONTROL node, on every host in the play - ansible.builtin.debug: msg: "{{ lookup('file', '/etc/motd') }}" # Reads /etc/motd on the MANAGED host - ansible.builtin.slurp: src: /etc/motd register: motd ``` The first form is a classic bug report: "the same content shows up for every host". Of course it does — it was read once, locally. The same applies to `lookup('env', ...)`, which reads the controller's environment, and to `lookup('pipe', ...)`, which runs a command locally. `ansible.builtin.template` is the reverse case and worth knowing as the counter-example: the Jinja2 rendering happens on the controller (an action plugin does it), and only the rendered bytes are shipped to the host by the underlying copy operation. That is why templating can reference `hostvars` for other hosts and why the template file itself never lands on the target. ## Action plugins: the bridge An action plugin runs on the controller and is invoked *instead of* going straight to a module. Some modules have a matching action plugin that does controller-side work first — resolving a source file, rendering a template, deciding which module to call — and then dispatches the real module to the host. When you hear "this module behaves oddly, it seems to do something locally first", an action plugin is usually the explanation. ## Practical implications for interviews - **Python dependency.** Modules need an interpreter on the target; plugins do not. That is why bootstrapping a bare host uses `ansible.builtin.raw`, which skips the module payload entirely. - **Where to put custom code.** A custom module goes in a `library/` directory beside the playbook or inside a role, or in a collection under `plugins/modules/`. A custom plugin goes in a directory named for its type — `lookup_plugins/`, `callback_plugins/`, `filter_plugins/` — or in a collection under `plugins/<type>/`. Non-stdout callbacks additionally have to be enabled in `ansible.cfg`. - **Debugging.** A module failure surfaces as a JSON parse error or a traceback from the remote interpreter; a plugin failure surfaces as a controller-side stack trace before any host is contacted. The shape of the error tells you which side broke. - **Discovery.** `ansible-doc -l` lists modules; `ansible-doc -t lookup -l` (or `-t callback`, `-t connection`) lists plugins of a type, and `ansible-doc -t lookup file` prints one plugin's documentation. Knowing this is how you avoid inventing a module name in an interview. Since ansible-core 2.10 both modules and plugins ship inside collections, addressed as `namespace.collection.name` — `ansible.builtin.copy`, `community.general.dig`. The `ansible.builtin` collection is the set that ships with the engine itself.

  • Someone reports that lookup('file', '/etc/hostname') returns the same value for every host in the play. Why?
    Because lookups are controller-side plugins. The expression is evaluated in the Ansible process on the control node, so it reads the control node's `/etc/hostname` once per evaluation — the managed hosts are never consulted. To get the remote file, use a module: `ansible.builtin.slurp` and decode the result, or read the value from a gathered fact such as `ansible_hostname`.
  • Why does the template module render Jinja2 on the control node rather than on the target?
    Because rendering is done by a controller-side action plugin before anything is transferred. That gives the template access to the full controller-side data model — every variable and `hostvars` for other hosts — and means the target needs no template engine and never receives the raw template. Only the rendered bytes are written to the host.
  • Where do you place a custom callback plugin so Ansible finds it?
    In a `callback_plugins/` directory beside the playbook, in a path listed in `ansible.cfg`, or inside a collection under `plugins/callback/`. Unlike modules, plugins are loaded by the controller, so no transfer happens. A callback that replaces stdout is selected with `stdout_callback`; additional ones must be listed under `callbacks_enabled` in `ansible.cfg`.

saying these in an interview costs you the question

  • Says plugins are just modules written in a different language
  • Expects lookup to read a file on the managed host
  • Thinks Ansible ships every module to every host up front
  • Believes Jinja2 is rendered on the target by template
  • Assumes plugins need Python installed on the target

context