Ansible Lesson 17 of 42

Ansible Plugins, In Depth: Filter, Lookup, Callback, Connection & the Whole Plugin System

If you have used Ansible for any length of time you have already used dozens of plugins without noticing — every | default(...) in a template, every lookup('env', ...) in a variable, the coloured task output scrolling past your terminal, the very SSH connection that carried your modules to the target. All of those are plugins. Yet most engineers stop at “I write playbooks and the occasional module” and never learn the layer underneath, which is a shame, because the plugin system is where Ansible stops being a tool you consume and becomes a tool you extend. The moment you need a data transformation no built-in filter offers, a way to pull secrets from a source Ansible doesn’t know about, output formatted for a system that ingests your CI logs, or a connection method for some appliance that speaks neither SSH nor WinRM, the answer is a plugin.

This lesson is the deep dive on that whole system. By the end you will understand the architectural line that separates modules (which are copied to and executed on the managed node) from plugins (which run in the Ansible process on the control node) — the single most clarifying idea in the entire subject. You will know every plugin type Ansible defines, as a reference table you will return to: action, become, cache, callback, cliconf, connection, filter, httpapi, inventory, lookup, netconf, shell, strategy, terminal, test, and vars. You will write a real filter plugin (a FilterModule whose filters() method returns a dict of names to functions) and a real lookup plugin (a LookupBase subclass whose run() method receives terms and variables), and learn the lookup versus query distinction from the author’s side. You will see how callback plugins hook the events of a run (and the rule that only one stdout callback may be active), how connection plugins and inventory plugins work (and why a YAML inventory file with a plugin: key has quietly replaced the old executable inventory scripts), and exactly how Ansible discovers plugins across ansible.cfg, role directories, and collections — with the precedence order spelled out. Everything targets current Ansible (ansible-core 2.17+ / 2.18, 2026) and uses FQCNansible.builtin.ssh, ansible.builtin.profile_tasks, community.general.dig — throughout. We finish with a free, local lab in which you write, install, and run your own filter and lookup plugins against localhost.

Learning objectives

After working through this lesson you will be able to:

Prerequisites & where this fits

This is an Advanced, Developing-tier lesson, and it assumes the foundations are already in place. You should be comfortable writing playbooks with plays, tasks and become; defining and referencing variables and facts; and using Jinja2 — the filter and lookup catalogue from Ansible Jinja2 Templating, In Depth is the consumer view of two of the plugin types you are about to learn to author. It pairs most closely with Writing Custom Ansible Modules, In Depth: a module and a plugin are both Python you ship with your automation, but they run in opposite places and follow completely different contracts, and knowing both is what makes you dangerous on the Red Hat Certified Specialist in Developing Automation with Ansible (EX374) exam, to which this lesson maps directly. You will also lean on your understanding of ansible.cfg and of collections (FQCN, plugins/ layout). All you need installed is ansible-core (ansible --version); the entire lab runs against localhost with the local connection and costs nothing.

Core concepts

Three ideas carry the whole lesson. Fix them before anything else.

Modules run on the target; plugins run on the controller. This is the distinction. A module (ansible.builtin.copy, your own custom module) is a self-contained program that Ansible copies to the managed node and executes there, in that host’s Python (or as a binary), returning JSON. A plugin is Python that loads into and runs inside the ansible/ansible-playbook process on the control node — it never travels to the target. A filter transforms data while Jinja2 renders on the controller; a lookup reads a file on your machine; a callback reacts to events as the controller emits them; a connection plugin is the very thing that carries a module to the target. So when you ask “where does this code execute and what can it touch?”, the answer for a module is “the managed node, that host’s filesystem and environment” and for a plugin is “the control node, its filesystem, its environment, the in-memory run state”. Get this right and every confusing behaviour (“why did lookup('file') read the wrong file?”) becomes obvious.

There is a plugin type for nearly every extension point in Ansible. Ansible is, internally, an engine wired together out of plugins. Choosing what to connect to a host with is a connection plugin; how to escalate privilege is a become plugin; how to lay out a command line for that host’s shell is a shell plugin; how to order and parallelise tasks is a strategy plugin; where the inventory comes from is an inventory plugin; how facts are cached between runs is a cache plugin; how a network device’s CLI is driven is a cliconf/terminal/netconf/httpapi plugin. You will mostly write filters, lookups, and the occasional callback — but knowing the full set tells you where to reach when the built-ins fall short, and the type list is itself an exam favourite.

Plugins are loaded by name, discovered along a search path, and resolved by precedence. Ansible finds a plugin of a given type by searching, in order, a set of directories — collection plugin dirs, adjacent-to-playbook directories, role plugin dirs, the configured *_plugins paths in ansible.cfg, and finally the bundled ansible.builtin plugins. When two plugins share a short name, the first one found wins, which is how you override a built-in and also how you accidentally shadow one. In modern Ansible the safe, unambiguous way to refer to a plugin is by its FQCN (namespace.collection.name), and ansible.builtin.<name> always denotes the bundled one regardless of what is on the path.

A few terms you will meet throughout:

Term Meaning
Plugin Python that runs inside the controller process to extend Ansible at a defined extension point (filter, lookup, callback, connection, …).
Module A program copied to and run on the managed node, returning JSON. (Different contract; see the custom-modules lesson.)
Plugin type One of Ansible’s defined categories (filter, lookup, callback, connection, become, cache, strategy, shell, vars, inventory, action, test, cliconf, httpapi, netconf, terminal).
FQCN Fully-Qualified Collection Name: namespace.collection.plugin, e.g. ansible.builtin.ssh, community.general.dig. The unambiguous way to name a plugin.
stdout_callback The single callback that owns what you see on screen during a run. Exactly one is active.
callbacks_enabled The config list of additional (aggregate/notification) callbacks to switch on alongside the stdout one.
Discovery path The ordered set of directories Ansible searches to find a plugin of a given type.

The full plugin-type table

This is the reference. Every extension point in Ansible is one of these types; each is loaded from a same-named directory inside a collection (plugins/<type>/) and from the corresponding *_plugins path in ansible.cfg. Note one quirk worth memorising: most types live under plugins/<typename>/, but module code lives under plugins/modules/, module utilities under plugins/module_utils/, and filter/test plugins are unusual in that one file can define many (a FilterModule/TestModule exporting a dict), whereas most other types are one-plugin-per-file.

Type What it does Runs on Author’s base / entry point Collection dir You’ll write one?
action The controller-side half of a module: pre-processes args, can run code locally, then (usually) invokes the real module. ansible.builtin.template/copy/fetch are action plugins. Controller subclass ActionBase, implement run() plugins/action/ Occasionally
become How privilege is escalated on the target (sudo, su, doas, runas, pbrun, pfexec, machinectl, …). Controller (builds the command) subclass BecomeBase plugins/become/ Rarely
cache Where gathered facts are stored between runs (jsonfile, redis, memcached, memory). Controller subclass BaseCacheModule plugins/cache/ Rarely
callback Reacts to events during a run — printing output, writing logs, sending notifications, recording timing. Controller subclass CallbackBase, implement v2_* hooks plugins/callback/ Sometimes
cliconf Abstracts sending/receiving config to a network device CLI (per-platform). Controller subclass CliconfBase plugins/cliconf/ Network only
connection Transports modules to and runs them on the target (ssh, paramiko_ssh, local, winrm, psrp, docker/community.docker.docker, kubectl). Controller subclass ConnectionBase plugins/connection/ Rarely
filter A Jinja2 transformation applied with | (default, combine, map, …). One file exports many via FilterModule.filters(). Controller class FilterModule with filters() plugins/filter/ Often
httpapi Talks to a device/service over its HTTP(S) API (network/appliance automation). Controller subclass HttpApiBase plugins/httpapi/ Network only
inventory Sources hosts and groups — from a file, a cloud API, etc. The modern replacement for inventory scripts. Controller subclass BaseInventoryPlugin (often + Constructable, Cacheable) plugins/inventory/ Sometimes
lookup Pulls external data into a play (file, env, password, a database, a secrets store). Returns a list. Controller subclass LookupBase with run() plugins/lookup/ Often
netconf Drives network devices over the NETCONF protocol. Controller subclass NetconfBase plugins/netconf/ Network only
shell How commands are assembled for the target’s shell (sh, bash, powershell, csh, fish). Controller (formats for target) subclass ShellBase plugins/shell/ Rarely
strategy Orchestrates how tasks run across hosts — linear (default), free, host_pinned, debug. Controller subclass StrategyBase plugins/strategy/ Rarely
terminal Handles terminal-level concerns (prompts, paging) for network CLI sessions; partners with cliconf. Controller subclass TerminalBase plugins/terminal/ Network only
test A Jinja2 yes/no test applied with is (defined, match, even). One file exports many via TestModule.tests(). Controller class TestModule with tests() plugins/test/ Sometimes
vars Injects variables automatically (e.g. host_group_vars loads host_vars//group_vars/). Controller subclass BaseVarsPlugin plugins/vars/ Rarely

Two members of the broader ecosystem that are not in the type list but are worth naming so you don’t confuse them: module code (plugins/modules/) — runs on the target, covered in the custom-modules lesson — and module_utils (plugins/module_utils/) — shared Python imported by modules, also shipped on the target. Everything in the table above, by contrast, executes only on the control node.

Filter plugins: writing one

A filter is a Python function exposed to Jinja2 under a name, callable with the pipe — value | yourfilter(arg). You author filters by writing a file containing a class named exactly FilterModule with a method filters(self) that returns a dictionary mapping filter names to the callables that implement them. The first positional argument each callable receives is the value to the left of the pipe; any (args) after the filter name become further positional/keyword arguments.

Here is a complete, runnable filter file defining three filters:

# filter_plugins/kv_filters.py   (or plugins/filter/kv_filters.py in a collection)
from __future__ import annotations

DOCUMENTATION = r"""
name: to_env
short_description: Render a dict as KEY=value lines for an env file
description:
  - Turns a mapping into newline-separated C(KEY=value) pairs.
options:
  _input:
    description: The dictionary to render.
    type: dict
    required: true
"""

def to_env(data):
    """{'A': 1, 'B': 'x'} -> 'A=1\nB=x'"""
    if not isinstance(data, dict):
        raise TypeError("to_env expects a dict, got %s" % type(data).__name__)
    return "\n".join("%s=%s" % (k, v) for k, v in data.items())

def shout(value, suffix="!"):
    """'hello' | community.demo.shout('!!') -> 'HELLO!!'"""
    return str(value).upper() + suffix

def mask(value, keep=4, char="*"):
    """Mask all but the last `keep` characters of a secret-ish string."""
    s = str(value)
    if len(s) <= keep:
        return char * len(s)
    return char * (len(s) - keep) + s[-keep:]

class FilterModule(object):
    """Ansible looks for this exact class name."""
    def filters(self):
        return {
            "to_env": to_env,
            "shout": shout,
            "mask": mask,
        }

Place that file in filter_plugins/ next to your playbook (or in a role’s filter_plugins/, or a collection’s plugins/filter/) and call it:

- ansible.builtin.debug:
    msg: "{{ {'PORT': 8080, 'ENV': 'prod'} | to_env }}"
# -> "PORT=8080\nENV=prod"

- ansible.builtin.debug:
    msg: "{{ 'deploy' | shout('!!') }}"          # -> "DEPLOY!!"

- ansible.builtin.debug:
    msg: "{{ 'sk-1234567890' | mask }}"          # -> "********7890"

The rules and gotchas that matter when writing filters:

Concern Rule
Class name Must be exactly FilterModule; the method must be filters() returning a dict.
First argument Is the piped value (left of |); subsequent args come from (…) after the name.
Return type Whatever the filter logically produces — string, list, dict, number, bool. Filters must not mutate their input; return a new value.
Many per file Unlike most plugin types, one file can export many filters — just add more keys to the dict.
Errors Raise AnsibleFilterError (from ansible.errors) for clean, contextual failures rather than bare exceptions.
Naming Call by FQCN in a collection (community.demo.shout); a short name works for adjacent/role filters but can collide with built-ins.
Docs Add DOCUMENTATION/EXAMPLES/RETURN (with an _input option) so ansible-doc -t filter community.demo.shout works.

The same shape, with the class renamed TestModule and the method tests(), defines test plugins — functions that return a boolean and are invoked with is (value is yourtest). Filters transform; tests answer yes/no.

Lookup plugins: writing one

A lookup pulls external data into a play from the control node. You author one by subclassing LookupBase (from ansible.plugins.lookup) and implementing run(self, terms, variables, **kwargs), which must return a list. terms is the list of positional arguments the caller passed (lookup('community.demo.reverse_lines', 'a.txt', 'b.txt')terms == ['a.txt', 'b.txt']); variables is the current variable context; named options (encoding=...) arrive in kwargs and, if you declare them in DOCUMENTATION, are best read with self.set_options() + self.get_option().

A complete lookup that reads files on the control node and returns their lines reversed:

# lookup_plugins/reverse_lines.py  (or plugins/lookup/reverse_lines.py)
from __future__ import annotations

DOCUMENTATION = r"""
name: reverse_lines
short_description: Return the lines of one or more files, reversed
description:
  - Reads each given path on the B(control node) and returns its lines in reverse order.
options:
  _terms:
    description: One or more file paths to read.
    type: list
    elements: str
    required: true
  encoding:
    description: Text encoding to read the file with.
    type: str
    default: utf-8
"""

from ansible.errors import AnsibleError
from ansible.plugins.lookup import LookupBase

class LookupModule(LookupBase):     # the class MUST be named LookupModule
    def run(self, terms, variables=None, **kwargs):
        self.set_options(var_options=variables, direct=kwargs)
        encoding = self.get_option("encoding")
        ret = []
        for term in terms:
            try:
                with open(term, "r", encoding=encoding) as fh:
                    lines = fh.read().splitlines()
            except OSError as e:
                raise AnsibleError("reverse_lines: cannot read %s: %s" % (term, e))
            ret.append("\n".join(reversed(lines)))   # one result per term
        return ret                                   # ALWAYS a list

Call it two ways — and the difference is the heart of the topic:

# lookup(): joins multiple results into ONE comma-separated string
- ansible.builtin.debug:
    msg: "{{ lookup('community.demo.reverse_lines', 'a.txt') }}"

# query() / q(): returns the real LIST — use this for loops
- ansible.builtin.debug:
    msg: "{{ item }}"
  loop: "{{ query('community.demo.reverse_lines', 'a.txt', 'b.txt') }}"

run() always returns a list; what differs is what the caller does with it. lookup() flattens that list into a single comma-joined string (handy in a scalar value, wrong for a loop); query() (alias q()) hands back the list untouched. As the author you simply return one element per logical result and let the caller pick. The author-side rules:

Concern Rule
Class name Must be exactly LookupModule, subclassing LookupBase.
Entry point run(self, terms, variables=None, **kwargs)must return a list.
terms The positional args (a list), in order.
variables The current variable context (host/play vars), for resolving things yourself.
Options Declare in DOCUMENTATION, then self.set_options(var_options=variables, direct=kwargs) and read with self.get_option('name').
Where it runs Control node, at templating time, every time the expression is evaluated. It never touches the target.
Errors Raise AnsibleError; for “skip this term” semantics, support an option rather than swallowing silently.
Secrets Callers should wrap secret-returning lookups with no_log: true; document that.
Templating helper Use self._templar / self.find_file_in_search_path() to honour Ansible’s file-search and templating where relevant.

Real-world lookups in the wild include ansible.builtin.file, ansible.builtin.env, ansible.builtin.pipe, ansible.builtin.password, ansible.builtin.first_found, ansible.builtin.url, and community ones such as community.general.dig (DNS), community.hashi_vault.hashi_vault (Vault), and the cloud secret-manager lookups (amazon.aws.aws_secret, azure.azcollection.azure_keyvault_secret) — every one of them just a LookupModule.run() returning a list.

Callback plugins: hooking the events of a run

A callback plugin receives a stream of events as a play runs and decides what to do with them — print them, colourise them, write them to a file, time them, post to Slack, emit JUnit XML for CI. You author one by subclassing CallbackBase (from ansible.plugins.callback) and implementing the v2_* hook methods for the events you care about; you also set class attributes declaring the plugin’s CALLBACK_VERSION, CALLBACK_TYPE, and CALLBACK_NAME.

The events arrive as well-named methods — implement only the ones you need:

Hook Fires when…
v2_playbook_on_start The playbook begins.
v2_playbook_on_play_start Each play begins.
v2_playbook_on_task_start Each task begins.
v2_runner_on_ok A task succeeds on a host (result._result has the data).
v2_runner_on_failed A task fails on a host.
v2_runner_on_skipped A task is skipped on a host.
v2_runner_on_unreachable A host is unreachable.
v2_runner_item_on_ok / _on_failed / _on_skipped Per-item, inside a loop.
v2_playbook_on_handler_task_start A handler runs.
v2_playbook_on_stats End of the run — the PLAY RECAP; stats has per-host counts.

The crucial operational rule is the three callback flavours and how they are enabled, set by CALLBACK_TYPE:

CALLBACK_TYPE Purpose How many active How enabled
stdout Owns the on-screen output of the run (default, minimal, oneline, yaml, dense, debug). Exactly one stdout_callback = <name> in ansible.cfg (or ANSIBLE_STDOUT_CALLBACK)
aggregate Adds extra behaviour without owning the screen — timing, profiling, recap helpers (profile_tasks, timer, profile_roles, cgroup_perf_recap). Many listed in callbacks_enabled (or ANSIBLE_CALLBACKS_ENABLED)
notification Sends results elsewhere — logs, files, chat, CI (log_plays, tree, junit, mail, say, community.general.slack). Many listed in callbacks_enabled

So you pick a single stdout_callback to control how the run looks, and you switch on any number of aggregate/notification callbacks via callbacks_enabled:

# ansible.cfg
[defaults]
stdout_callback = ansible.builtin.yaml          # the ONE screen callback
callbacks_enabled = ansible.builtin.profile_tasks, ansible.builtin.timer

That gives you YAML-formatted output plus a per-task timing report and a total run timer. One subtlety: by default only stdout callbacks load for ad-hoc ansible commands; to let aggregate/notification callbacks fire for ad-hoc runs too, set bin_ansible_callbacks = true. Community/third-party callbacks must also be adopted into callbacks_enabled by FQCN. A minimal worked callback that just times the whole run:

# callback_plugins/runtimer.py  (or plugins/callback/runtimer.py)
from __future__ import annotations
import time
from ansible.plugins.callback import CallbackBase

class CallbackModule(CallbackBase):          # class MUST be CallbackModule
    CALLBACK_VERSION = 2.0
    CALLBACK_TYPE = "aggregate"              # adds behaviour; doesn't own stdout
    CALLBACK_NAME = "community.demo.runtimer"
    CALLBACK_NEEDS_ENABLED = True            # must be listed in callbacks_enabled

    def v2_playbook_on_start(self, playbook):
        self._t0 = time.time()

    def v2_playbook_on_stats(self, stats):
        self._display.display("Total run time: %.1fs" % (time.time() - self._t0))

Connection & inventory plugins

Connection plugins

A connection plugin is the transport: it is the thing that takes a module, gets it onto the target, runs it, and brings the result back. You select one with the ansible_connection variable (or connection: on a play, or -c on the CLI). The built-ins:

Connection plugin (FQCN) Transport Use for
ansible.builtin.ssh The OpenSSH client binary The default for Linux/Unix — fast, uses ControlPersist multiplexing.
ansible.builtin.paramiko_ssh SSH via the paramiko Python library When the OpenSSH binary is unavailable or you need its specific behaviour.
ansible.builtin.local Runs on the control node localhost, delegate_to: localhost, control-node tasks.
ansible.builtin.winrm WinRM Windows targets (classic).
ansible.builtin.psrp PowerShell Remoting over HTTP(S) Windows targets (often faster/more capable than winrm).
community.docker.docker docker exec Managing inside a running container, no SSH.
community.general.chroot / jail / zone chroot / BSD jail / Solaris zone Local container-like contexts.
kubernetes.core.kubectl kubectl exec Managing inside a Kubernetes pod.

Writing one means subclassing ConnectionBase and implementing _connect(), exec_command(), put_file(), fetch_file(), and close() — rare, but it is exactly how you’d automate an appliance that speaks some bespoke transport. The everyday point: connection: local (the local plugin) is why delegate_to: localhost and control-node lookups work, and choosing psrp over winrm, or ssh pipelining settings, is a connection-plugin tuning decision.

Inventory plugins (and why they replaced scripts)

An inventory plugin produces the hosts, groups, and variables Ansible acts on. Historically you wrote an executable inventory script that dumped a particular JSON shape on --list; that still works for backward compatibility via the ansible.builtin.script inventory plugin, but it is legacy. The modern, recommended approach is a YAML configuration file whose first key is plugin:, naming the inventory plugin to run — declarative, cacheable, and consistent:

# inventory.aws_ec2.yml  -> use with: ansible-inventory -i inventory.aws_ec2.yml --graph
plugin: amazon.aws.aws_ec2
regions:
  - ap-south-1
keyed_groups:
  - key: tags.Environment
    prefix: env
filters:
  instance-state-name: running

The plugins you’ll meet:

Inventory plugin (FQCN) Sources hosts from…
ansible.builtin.ini A static INI inventory ([web] … groups).
ansible.builtin.yaml A static YAML inventory.
ansible.builtin.toml A static TOML inventory.
ansible.builtin.host_list / advanced_host_list A comma list on the CLI (-i host1,host2,).
ansible.builtin.script A legacy executable inventory script (back-compat).
ansible.builtin.constructed Builds groups/vars from Jinja2 on top of an existing inventory (compose, keyed_groups, groups).
ansible.builtin.auto The dispatcher: reads a *.yml’s plugin: key and loads that plugin. Enabled by default.
amazon.aws.aws_ec2, azure.azcollection.azure_rm, google.cloud.gcp_compute Live cloud inventories.
community.general.proxmox, Various platforms.

Two configuration essentials. First, an inventory plugin only runs if it is in the enable_plugins list under [inventory] in ansible.cfg (the default list includes host_list, script, auto, yaml, ini, toml — note auto is what dispatches your cloud *.yml, so cloud plugins work as long as auto is enabled and the collection is installed). Second, the *.yml filename often must match a recognised suffix (e.g. *.aws_ec2.yml) for auto to pick the right plugin. Writing your own inventory plugin means subclassing BaseInventoryPlugin (commonly mixing in Constructable and Cacheable) and implementing verify_file() and parse(); the payoff over a script is free caching, the constructed-style compose/keyed_groups, and proper option/DOCUMENTATION handling.

Plugin discovery paths & precedence

When you reference a plugin, Ansible searches for it across a fixed set of locations, and the first match wins. Knowing the order lets you deliberately override a built-in — and diagnose accidental shadowing. The search order, highest precedence first:

  1. A collection, when you use the FQCN (namespace.collection.name) — Ansible loads exactly that collection’s plugins/<type>/ file. (ansible.builtin.<name> always resolves to the bundled plugin.)
  2. A directory adjacent to the playbook named for the type — e.g. filter_plugins/, lookup_plugins/, callback_plugins/, connection_plugins/, library/ (for modules), sitting beside the playbook you run.
  3. Inside a role that is currently in scope — the role’s filter_plugins/, lookup_plugins/, library/, etc. (loaded when the role is used).
  4. The configured *_plugins paths in ansible.cfg (and the matching ANSIBLE_*_PLUGINS env vars) — see the table below.
  5. The bundled ansible.builtin plugins shipped with ansible-core (the lowest precedence, the fallback).

The ansible.cfg settings that define location 4, by type:

ansible.cfg key (under [defaults]) Env var Plugin type Historic default path
filter_plugins ANSIBLE_FILTER_PLUGINS filter ~/.ansible/plugins/filter:/usr/share/ansible/plugins/filter
test_plugins ANSIBLE_TEST_PLUGINS test …/plugins/test
lookup_plugins ANSIBLE_LOOKUP_PLUGINS lookup …/plugins/lookup
callback_plugins ANSIBLE_CALLBACK_PLUGINS callback …/plugins/callback
connection_plugins ANSIBLE_CONNECTION_PLUGINS connection …/plugins/connection
action_plugins ANSIBLE_ACTION_PLUGINS action …/plugins/action
inventory_plugins ANSIBLE_INVENTORY_PLUGINS inventory …/plugins/inventory
vars_plugins ANSIBLE_VARS_PLUGINS vars …/plugins/vars
cache_plugins ANSIBLE_CACHE_PLUGINS cache …/plugins/cache
strategy_plugins ANSIBLE_STRATEGY_PLUGINS strategy …/plugins/strategy
library ANSIBLE_LIBRARY module ~/.ansible/plugins/modules:/usr/share/ansible/plugins/modules
module_utils ANSIBLE_MODULE_UTILS module_utils …/plugins/module_utils

Three practical consequences. First, the adjacent and role directories use the legacy plural names (filter_plugins/, lookup_plugins/, callback_plugins/, library/), whereas collections use the singular type name (plugins/filter/, plugins/lookup/, plugins/callback/, plugins/modules/) — a constant source of confusion. Second, because the first match wins, dropping a filter_plugins/json_query.py next to your playbook will override a built-in or collection filter of the same short name — powerful but a footgun, which is why FQCN exists. Third, the canonical way to verify what is actually loadable is ansible-doc: ansible-doc -t filter -l, ansible-doc -t lookup -l, ansible-doc -t callback -l, and ansible-doc -t filter community.demo.shout to read a specific one’s docs — if it shows up there, Ansible can find it.

Ansible plugin system: the module/plugin split, the plugin types, and the discovery path

The diagram contrasts modules being shipped to and executed on the managed node against the plugin types (filter, lookup, callback, connection, inventory, …) all running inside the controller process, and traces the discovery search order from a collection’s plugins/<type>/ through adjacent and role directories and the configured *_plugins paths down to bundled ansible.builtin.

Hands-on lab

You will write, install, and run your own filter plugin and your own lookup plugin — entirely free and local, targeting localhost with the local connection, so there is no cloud and no cost. You only need ansible-core (ansible --version).

1. Create the lab layout. Make a plugin-lab/ directory containing an ansible.cfg, a filter_plugins/ dir, a lookup_plugins/ dir, a sample data file, and a playbook.

plugin-lab/ansible.cfg:

[defaults]
inventory = localhost,
filter_plugins = ./filter_plugins
lookup_plugins = ./lookup_plugins
stdout_callback = ansible.builtin.yaml
callbacks_enabled = ansible.builtin.profile_tasks, ansible.builtin.timer
retry_files_enabled = false

plugin-lab/filter_plugins/kv_filters.py — paste the filter file from the “Filter plugins” section above (the one defining to_env, shout, mask).

plugin-lab/lookup_plugins/reverse_lines.py — paste the lookup file from the “Lookup plugins” section above.

plugin-lab/sample.txt:

first line
second line
third line

plugin-lab/play.yml:

- name: Exercise a custom filter and a custom lookup
  hosts: localhost
  connection: local           # the ansible.builtin.local connection plugin
  gather_facts: false
  tasks:
    - name: Use the custom 'to_env' and 'mask' filters
      ansible.builtin.debug:
        msg: "{{ {'PORT': 8080, 'ENV': 'prod', 'SECRET': 'sk-12345678'} | to_env }}"

    - name: Use the custom 'shout' filter with an argument
      ansible.builtin.debug:
        msg: "{{ 'deploy' | shout('!!') }}"

    - name: lookup() joins results into one string
      ansible.builtin.debug:
        msg: "{{ lookup('reverse_lines', 'sample.txt') }}"

    - name: query() returns a real list (drive a loop)
      ansible.builtin.debug:
        msg: "reversed file -> {{ item }}"
      loop: "{{ query('reverse_lines', 'sample.txt') }}"

2. Confirm Ansible can see your plugins. From inside plugin-lab/:

ansible-doc -t filter -l | grep -E 'to_env|shout|mask'
ansible-doc -t lookup -l | grep reverse_lines

Both should list your plugins (proving discovery via the *_plugins paths worked). ansible-doc -t lookup reverse_lines should print the documentation you wrote.

3. Run it.

ansible-playbook --syntax-check play.yml
ansible-playbook play.yml

Expected: YAML-formatted output (your stdout_callback), a per-task timing block and a total timer at the end (your callbacks_enabled aggregate callbacks), the to_env task printing PORT=8080\nENV=prod\nSECRET=sk-12345678, shout printing DEPLOY!!, the lookup task printing the three lines reversed as one string, and the query loop iterating that same reversed content.

4. Prove the lookup vs query difference. Add a second file sample2.txt, then change the loop task to query('reverse_lines', 'sample.txt', 'sample2.txt') and re-run — the loop now iterates two elements (one per file). Switch it to lookup('reverse_lines', 'sample.txt', 'sample2.txt') in a debug.msg and you’ll see the two results comma-joined into one string — exactly why query() is the right call for loops.

5. Demonstrate precedence/shadowing. Add a mask filter to a second file filter_plugins/aaa_override.py (same FilterModule/filters() shape) that returns "OVERRIDDEN", re-run, and observe which one wins (first found along the path). Delete it afterwards. This is the override footgun in miniature — and the reason production code uses FQCN inside collections.

Validation. You have authored a filter plugin and a lookup plugin, made Ansible discover them via ansible.cfg paths, confirmed them with ansible-doc, exercised lookup vs query, and switched stdout/aggregate callbacks on — the whole plugin system in one directory.

Cleanup.

# just delete the lab directory
rm -rf plugin-lab

Cost note. Everything ran against localhost with the local connection — no cloud resources, no remote hosts, ₹0.

Common mistakes & troubleshooting

Symptom Cause Fix
template error … 'myfilter' is undefined The filter file isn’t on a discovery path, or the class isn’t named FilterModule Put it in filter_plugins/ (adjacent/role) or plugins/filter/ (collection); confirm the class is FilterModule with a filters() dict; check ansible-doc -t filter -l.
Lookup returns one comma-joined blob into a loop: You used lookup() (which flattens) where you needed a list Use query()/q(), or lookup(..., wantlist=true).
lookup('file', …) reads the wrong file (your machine, not the target) Lookups run on the control node, always To read a file on the target use ansible.builtin.slurp/fetch; lookups are controller-only by design.
Callback “doesn’t fire” It’s an aggregate/notification callback not listed in callbacks_enabled (or it needs CALLBACK_NEEDS_ENABLED) Add it by name to callbacks_enabled; for ad-hoc ansible, also set bin_ansible_callbacks = true.
Two custom stdout callbacks, only one shows Only one stdout_callback can be active Pick a single stdout_callback; make extras aggregate/notification and enable via callbacks_enabled.
A cloud inventory *.yml is ignored The auto inventory plugin isn’t enabled, the collection isn’t installed, or the filename suffix isn’t recognised Ensure auto is in [inventory] enable_plugins, install the collection, name the file with the expected suffix (e.g. *.aws_ec2.yml).
lookup option encoding ignored You didn’t call self.set_options() / declare it in DOCUMENTATION Declare the option, call self.set_options(var_options=variables, direct=kwargs), read self.get_option('encoding').
A built-in filter/lookup behaves unexpectedly A same-named plugin adjacent to the playbook or in a role shadows it (first match wins) Reference the built-in by FQCN (ansible.builtin.…); remove or rename the shadowing file.
Lookup raises an ugly Python traceback You let a bare exception escape run() Catch and raise AnsibleError(...) for a clean, contextual message.
Filter mutates a list passed in and “leaks” changes Filters must be pure Return a new value; never modify the input in place.

Best practices

Security notes

Because plugins execute in the controller process with the controller’s privileges, they are a more sensitive trust boundary than modules. Treat any third-party plugin (especially callback and vars plugins, which can load and run automatically) the way you’d treat any code you execute on your automation host — review it before enabling it, and pin collection versions. Lookups and the pipe/url family read your control node’s filesystem, environment, and network, so a malicious or careless lookup can exfiltrate local secrets; keep the control node’s filesystem and ANSIBLE_* environment trusted, and never debug-print the output of a secrets lookup. When you write a secrets-fetching lookup (Vault, a cloud secret manager), document that callers must set no_log: true on tasks that use it, and remember that -vvvv/ANSIBLE_DEBUG=1 can defeat no_log — so never run secret-bearing plays at maximum verbosity. Callback plugins see every task result, including data a task tried to hide; a logging/notification callback can leak secrets into CI logs or chat, so audit what your callbacks emit and honour no_log. Finally, the first-match-wins discovery order means a plugin dropped adjacent to a playbook silently overrides a trusted built-in — review the filter_plugins/, lookup_plugins/, callback_plugins/, and library/ directories of any repository before you run its playbooks, and prefer FQCN so you always know which implementation runs.

Interview & exam questions

1. What is the fundamental difference between a module and a plugin in Ansible? A module is copied to and executed on the managed node, returning JSON; a plugin runs inside the ansible/ansible-playbook process on the control node and never travels to the target. Filters, lookups, callbacks, connection, inventory, become, cache, strategy, shell, and vars are all plugins; the thing you notify or package-install is a module.

2. Name as many plugin types as you can and what each does. action (controller-side half of a module), become (privilege escalation), cache (fact storage), callback (event reactions/output), cliconf (network CLI config), connection (transport), filter (Jinja2 transforms), httpapi (device HTTP API), inventory (host sources), lookup (pull external data in), netconf (NETCONF), shell (target shell command assembly), strategy (task orchestration — linear/free), terminal (network terminal handling), test (Jinja2 yes/no), vars (auto-inject variables).

3. How do you write a filter plugin? Create a file with a class named exactly FilterModule whose filters() method returns a dict mapping filter names to callables. The first argument each callable receives is the piped value; subsequent args come from (…). One file can define many filters. Place it in filter_plugins/ (adjacent/role) or plugins/filter/ (collection).

4. How do you write a lookup plugin, and what must run() return? Subclass LookupBase in a class named LookupModule and implement run(self, terms, variables, **kwargs), which must return a list. terms are the positional args; declare options in DOCUMENTATION and read them with self.set_options() + self.get_option().

5. lookup vs query — what’s the difference and which do you use in a loop? run() always returns a list. lookup() flattens it into a single comma-joined string; query() (alias q()) returns the list as-is. Use query() for loop: sources (or lookup(..., wantlist=true)).

6. Where does a lookup execute, and what’s the consequence for lookup('file', ...)? On the control node, at templating time, every evaluation. So lookup('file', '/etc/x') reads your machine’s /etc/x, not the target’s — to read a file on the target you use slurp/fetch.

7. Explain the three callback flavours and how each is enabled. stdout owns the on-screen output — exactly one active, set with stdout_callback. aggregate adds behaviour (timing/profiling) and notification sends results elsewhere (logs/chat/CI) — any number active, switched on by listing them in callbacks_enabled. For ad-hoc ansible, aggregate/notification callbacks also need bin_ansible_callbacks = true.

8. What’s the difference between an inventory plugin and an inventory script? A script is a legacy executable that dumps a fixed JSON shape on --list (supported via the script plugin for back-compat). An inventory plugin is configured by a declarative *.yml with a plugin: key, gets caching and constructed-style compose/keyed_groups, and validates options — the modern, recommended approach.

9. What is the plugin discovery precedence order? Highest first: (1) a collection via FQCN, (2) a directory adjacent to the playbook (filter_plugins/, lookup_plugins/, library/, …), (3) a role’s plugin dirs, (4) the configured *_plugins paths in ansible.cfg, (5) the bundled ansible.builtin plugins. First match wins, which is why FQCN prevents accidental shadowing.

10. Why do adjacent/role plugin directories use plural names but collections use singular? Legacy locations use filter_plugins/, lookup_plugins/, callback_plugins/, library/ (modules); collections standardise on the singular type name under plugins/plugins/filter/, plugins/lookup/, plugins/callback/, plugins/modules/. It’s a historical inconsistency you simply memorise.

11. How would you confirm Ansible can actually load a custom plugin you wrote? Run ansible-doc -t <type> -l (e.g. ansible-doc -t filter -l) and look for it; ansible-doc -t lookup yourname prints its docs. If it’s listed there, discovery worked; if not, the path or the class name (FilterModule/LookupModule/CallbackModule) is wrong.

12. From a security standpoint, why are plugins more sensitive than modules? Plugins run in the controller process with its privileges and access to the control node’s filesystem, environment, and network — so a malicious lookup/callback/vars plugin can read local secrets or leak task data into logs. The first-match-wins path also lets an adjacent file silently override a trusted built-in, so review a repo’s plugin dirs and pin collection versions before running its playbooks.

Quick check

  1. A module runs on the ____ ; a plugin runs on the ____.
  2. What exact class name and method define a filter plugin? A lookup plugin’s class name and entry-point method?
  3. You need a real list from a lookup to drive a loop:. Which function do you call?
  4. How many stdout_callback plugins can be active at once, and how do you enable an aggregate callback like profile_tasks?
  5. Two filter_plugins/ files both define a mask filter. Which one is used?

Answers

  1. A module runs on the managed node (target); a plugin runs on the control node (controller process).
  2. A filter plugin: class FilterModule with method filters() returning a dict. A lookup plugin: class LookupModule (subclass of LookupBase) with run(self, terms, variables, **kwargs) returning a list.
  3. query() (alias q()), or lookup(..., wantlist=true). Plain lookup() returns a comma-joined string.
  4. Exactly one stdout_callback. Enable an aggregate callback by adding it to callbacks_enabled in ansible.cfg (and set bin_ansible_callbacks = true to have it fire for ad-hoc ansible too).
  5. The first one found along the discovery path wins (e.g. the file that sorts/loads first) — which is exactly why production code names plugins by FQCN inside a collection.

Exercise

Extend the lab into a small, well-documented plugin set.

  1. Add a filter to_age(epoch) to kv_filters.py that converts a Unix timestamp into a human string like "3d 4h ago", and call it from the playbook. Give it a DOCUMENTATION block and confirm ansible-doc -t filter to_age renders.
  2. Add an option to your reverse_lines lookup — limit: int — that returns only the first N reversed lines per file. Declare it in DOCUMENTATION, read it with self.get_option('limit'), and prove query('reverse_lines', 'sample.txt', limit=2) honours it.
  3. Write a test plugin file (TestModule with a tests() dict) exposing a test is_private_ip that returns true for RFC-1918 addresses, and use it in a when: (when: some_ip is is_private_ip).
  4. Write a tiny notification callback (CALLBACK_TYPE = "notification", CALLBACK_NEEDS_ENABLED = True) that appends one line per failed task to failures.log via v2_runner_on_failed; enable it in callbacks_enabled and force a failure to test it.
  5. Finally, restructure all of the above into a collection skeleton (namespace/demo/plugins/filter/, plugins/lookup/, plugins/test/, plugins/callback/), install it, and call everything by FQCN (namespace.demo.shout, query('namespace.demo.reverse_lines', …)). Write two sentences on why FQCN removes the shadowing risk you saw in lab step 5.

This mirrors the EX374 expectation that you can author and package plugins, not just consume them.

Certification mapping

This lesson maps to the Red Hat Certified Specialist in Developing Automation with Ansible Automation Platform (EX374) exam, the advanced developer credential that goes beyond the RHCE’s playbook-authoring focus. EX374 objectives this lesson covers include develop custom plugins (writing and packaging filter, lookup, and other plugins), work with custom filters and lookups (the FilterModule.filters() and LookupBase.run() contracts, lookup vs query), manage content collections (where plugins live — plugins/<type>/ — and how FQCN resolves them), and the broader theme of extending Ansible’s behaviour with custom code. It complements Writing Custom Ansible Modules, In Depth (the other half of “Ansible code you write” — modules run on the target, plugins on the controller) and the Jinja2 lesson, which is the consumer view of the filters and lookups you now know how to author. While plugin internals are not on the RHCE EX294, the filter/lookup/inventory usage there is everyday RHCE territory, so this knowledge reinforces both exams.

Glossary

Next steps

You now understand Ansible’s plugin system end to end: the controller-side execution model, every plugin type, how to author filters and lookups (and the lookup/query distinction), how callbacks hook a run and why only one stdout callback may be active, what connection and inventory plugins do, and exactly how Ansible discovers and prioritises plugins. The natural next move is to package the modules and plugins you write into distributable, versioned units and give them a portable runtime. Continue with Building Ansible Collections & Execution Environments, In Depth: galaxy.yml, ansible-builder & EEs, which takes the plugins/<type>/ layout you just learned and wraps it into a publishable collection plus a container image that carries ansible-core, your collections, and their Python/system dependencies. To revisit the other half of Ansible code you write — the part that runs on the target — see Writing Custom Ansible Modules, In Depth, and for the consumer view of the filters, tests, and lookups you can now author yourself, Ansible Jinja2 Templating, In Depth.

ansiblepluginsfilter-pluginlookup-plugincallbackex374
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments