Custom Plugins
Plugins extend Ansible on the control node, not on the target. They run inside the Ansible process before or during a play, where a module runs out on the managed host. That single difference in where the code executes is the thing to hold onto: a plugin sees the control node's filesystem, credentials, and network position, because that is where it lives.
Three plugin types cover almost everything you would write. A filter plugin transforms data inside a Jinja2 expression, a lookup plugin pulls external data into a variable, and a callback plugin reacts to play events — most often for custom logging or output. All three are short Python files that Ansible discovers from a typed plugin directory, and the type you pick is dictated by when in a play you need to act.
The Plugin-vs-Module Line
Modules are shipped to the node and executed there with the task's privileges. Plugins execute in the control process, with the control node's credentials and environment. That is the whole reason a lookup('file', ...) reads a file on your laptop and not on web1.larkspur.io: the lookup ran on the controller, where the play is orchestrated, never on the host the play targets. If you ever forget which is which, ask where the work has to happen — on the remote machine, or in Ansible's own process.
changed and results as JSON. Reach for it to do something to a host.Filter Plugins
A filter plugin is a Python function registered in a FilterModule class, callable in Jinja2 like any built-in filter: {{ backends | larkspur.web.to_haproxy_backend }}. You write one when the built-in filters plus community.general cannot express the transform you need — a domain-specific reshaping of data that would otherwise be an unreadable pile of nested Jinja2. It lives in filter_plugins/ next to a playbook, or in a collection's plugins/filter/.
# a FilterModule exposes named functions to Jinja2 def to_haproxy_backend(hosts, port=8080): return [ f"server {h['name']} {h['ip']}:{port} check" for h in hosts ] class FilterModule: def filters(self): return {"to_haproxy_backend": to_haproxy_backend}
The function is plain Python — it takes the value piped into it plus any arguments, and returns the transformed value. The filters() method is the registration: its dictionary maps the Jinja2 name to the function, and from then on the filter is usable in any template the play renders.
Lookup Plugins
A lookup plugin is invoked as {{ lookup('larkspur.web.app_version', env) }} and fetches data at templating time from a source Ansible does not natively read — an internal release API, a custom secrets store, a database. It returns a value into a variable and runs on the control node every time it is referenced. This is the plugin you reach for when a task needs a piece of external data before it runs, because a lookup feeds variables on the way in, where a callback only sees results on the way out.
Callback Plugins
Callback plugins hook the play's lifecycle events — v2_runner_on_ok, v2_runner_on_failed, v2_playbook_on_stats — and are how you ship per-task results to a logging system, post a deploy summary to Slack, or replace Ansible's default human-readable output entirely. One callback can be the stdout callback that owns the on-screen output; others run alongside it as notification plugins. Because they fire on events as the play progresses, they are the right tool for reacting to what happened, and the wrong tool for supplying data a task needs up front.
Discovery and Packaging
Each plugin type has its own directory: filter_plugins/, lookup_plugins/, and callback_plugins/ beside a playbook, or plugins/<type>/ inside a collection. Filters and lookups are discovered and usable the moment the file is present. Callbacks are the exception — they often need explicit enabling in ansible.cfg under callbacks_enabled, which is the single most common reason a freshly written callback never seems to fire. Collection plugins are addressed by FQCN, the same as modules.
Module — a program Ansible copies to the managed node and runs there, with the task's privileges, returning changed and results as JSON. Reach for a module to do something to a host: install a package, template a file, call an API that should leave the remote machine in a state.
Plugin — Python that runs in the Ansible process on the control node: a filter transforms data, a lookup fetches it, a callback reacts to events. Reach for a plugin to extend how Ansible itself behaves. The test is one line: if it touches the remote host, it is a module; if it touches Ansible's own data flow or output, it is a plugin.
- Expecting a lookup to read a file on the managed node — lookups run on the control node, so
lookup('file', ...)reads your filesystem; the way to read remote file content is theslurpmodule. - Writing a filter plugin for a transform that
community.generalalready ships —dict2items,json_query, and dozens more exist, so a hand-rolled duplicate is maintenance for nothing. - Building a callback plugin and never enabling it in
ansible.cfgundercallbacks_enabled, then debugging for an hour why the custom logging never fires when the file was correct all along. - Putting secrets-fetching logic in a callback, which only sees results after a task has run, instead of a lookup, which feeds variables before tasks run — the right data at the wrong time is still the wrong hook.
- Assuming a custom plugin's credentials come from the target host — they are the control node's, so a lookup hitting an internal API uses the controller's network position and tokens, not the host's.
- Decide module vs plugin by where the work happens — remote host means a module, control-node data or output means a plugin — before writing a line of code.
- Reach for a lookup to pull external data into variables, and keep callbacks for reacting to events like logging and notifications, matching the hook to the timing of when you need it.
- Check the built-in filters and
community.generalbefore writing a filter plugin, so the only filter code you own is the code that does not already exist. - Enable callback plugins explicitly in
ansible.cfgand decide which single callback ownsstdout, so output and logging are deliberate rather than two plugins colliding over the screen. - Package any plugin reused across projects in a collection under
plugins/<type>/with an FQCN, the same as a custom module, rather than copying the file between repos.
Knowledge Check
Why does {{ lookup('file', '/etc/hosts') }} read a file on your laptop rather than on web1?
- Lookups are plugins that run in the Ansible process on the control node, so they see the controller's filesystem, not the target's
- The
filelookup alone is hard-coded to the control node, but every other lookup reads from the managed target host instead of the controller - Ansible caches the target's
/etc/hostslocally before each run and the lookup reads the cache - Lookups always run dead last in a task, only after the SSH connection to the target has already been closed
You need a task to pull a version string from an internal release API before it runs, feeding it into a variable. Which plugin type fits?
- A lookup plugin, which fetches external data at templating time and returns it into a variable
- A callback plugin, which can read the release API response and inject the version string into the upcoming tasks
- A filter plugin, since fetching the version is just a transform of an empty input
- A custom module, because any external API call has to run on the managed node during the task itself
Which plugin type reacts to play lifecycle events like v2_runner_on_failed for custom logging?
- A callback plugin, which hooks events as the play progresses and can ship results to a logging system
- A lookup plugin, which subscribes to every task result as it completes and forwards each one onward to the logger
- A filter plugin, which Ansible invokes automatically once for every failed task in the play
- A module, which reports its own task failures back from the managed node to the controller
A correctly written callback plugin is in callback_plugins/ but never fires. What is the most likely cause?
- It was not enabled in
ansible.cfgundercallbacks_enabled, which callbacks often require - Callback plugins only run on the managed node, which has no copy of the file
- The plugin must first be compiled into the execution environment image before Ansible will agree to load it
- Only one callback can exist per project, and a built-in callback is permanently shadowing this custom one
You got correct