The Plugin Ecosystem
Modules are the part of Ansible everyone learns first, but they are one plugin type among many — almost every moving part of Ansible is a pluggable component you can swap or extend. Callback plugins shape the output you see, lookup plugins pull data at template time, filters and tests transform values in Jinja2, connection plugins reach the node, strategy plugins decide task ordering across hosts, and become plugins handle privilege escalation.
profile_tasks timing profile, a log sink.to_nice_json, combine, and the rest.ssh, local, winrm, network_cli.Knowing the plugin types is knowing where Ansible's behavior comes from and where you would hook in to change it. When the output isn't what you want, when a value needs reshaping, or when a play needs data from outside, the answer is usually "there is a plugin for that" rather than a shell task glued in to fake it. This topic maps the landscape so you can name the part you need to touch.
Modules vs Plugins
The distinction that organizes everything else: modules are code copied to the managed node and executed there — they do the work on the host, like installing a package or writing a file. Plugins run on the control node and extend Ansible's own behavior. A callback that formats output, a filter that transforms a value, a lookup that fetches data — none of these ship to the target, because they shape what Ansible does, not what happens on the host. If it runs on the managed node it's a module; if it shapes Ansible itself it's a plugin.
Output and Logging — Callback Plugins
Callback plugins consume play events and decide what reaches your screen or a log sink. The default is the human-readable stdout you see on every run, but community.general ships callbacks that send results to JSON, produce a timing profile with profile_tasks, or forward to a log aggregator. Swapping a callback re-shapes the entire run's output without touching a single playbook — which is how you get per-task timing in CI without editing any tasks, just by enabling the callback in ansible.cfg.
Data Pull-in — Lookup and Vars Plugins
Lookups fetch external data into a play at template time: lookup('file', ...) reads a file, lookup('env', ...) reads an environment variable, lookup('aws_secret', ...) pulls a secret. They run on the controller and return a value into your template, idempotently, with no shell task in sight. Vars plugins are a quieter relative — they are the mechanism that auto-loads group_vars and host_vars, so variables appearing for a group is a vars plugin doing its job, not magic.
Value Transformation — Filters and Tests
Jinja2 filters transform values — | to_nice_json pretty-prints, | ansible.builtin.combine merges two dicts — and tests ask boolean questions, like is defined or is match. Both are plugins, which is the useful part: when your template needs a transformation that isn't built in, a collection can ship the exact filter, and you install it rather than hand-rolling the logic in a set_fact chain. The filter you want usually already exists somewhere in the ecosystem.
Run Behavior — Connection, Strategy, Inventory, Become
The shape of a run is assembled from several plugin types working together. Connection plugins, the last topic, reach the node. Strategy plugins (linear, free) order tasks across hosts. Inventory plugins build the host list, whether from a static file or a live cloud API. Become plugins (sudo, su, doas) escalate privilege on the target. None of these are settings buried in the engine — they are pluggable parts, which is why you can change how privilege escalation works or how hosts are discovered without patching Ansible itself.
Where Plugins Live and Load From
Plugins come from three places. Ansible's built-ins ship in ansible-core. Installed collections carry namespaced plugins you reference as namespace.collection.plugin. And project-local plugin directories sit beside the playbook — filter_plugins/ for custom filters, callback_plugins/ for callbacks, library/ for custom modules — discovered by directory name. "Almost everything is a pluggable type" is the design, and it means your repo can carry its own filters and callbacks, versioned alongside the playbooks that use them.
# ansible.cfg — turn on per-task timing for every run, no playbook edits [defaults] callbacks_enabled = ansible.posix.profile_tasks # in a task — pull a secret with a lookup plugin instead of a shell command api_token: "{{ lookup('community.general.passwordstore', 'larkspur/api_token') }}"
The callback line re-shapes output for the whole run from one config setting. The lookup pulls a secret into a variable at template time on the controller — a plugin doing cleanly what a command task would do messily and non-idempotently. Neither touches the managed node; both are controller-side plugins shaping the run.
Module — packaged code copied to the managed node and run there to change state (apt, copy, service), reporting back what it changed. If it executes on the target, it's a module.
Plugin — runs on the control node and extends Ansible itself: callbacks format output, lookups fetch data, filters transform values, strategies order tasks. If it shapes Ansible's own behavior rather than the host's state, it's a plugin.
- Calling everything a "module" and then being confused why a custom Jinja2 filter or a callback never ships to the managed node — those are plugins that run on the controller, not modules copied to the host.
- Writing a shell task to fetch a secret from a file or env var when a
lookupplugin already does it cleanly at template time, reinventing a built-in plugin as imperative glue. - Dropping a custom filter into the wrong directory name — not
filter_plugins/beside the play, and not namespaced in a collection — so Ansible never discovers it and the template errors on an "unknown filter." - Ignoring callback plugins and parsing raw stdout in CI for timing or results, when
profile_tasksand a JSON callback give structured output for free. - Assuming a missing filter or test means you must patch Ansible, when the right one usually lives in an installable collection like
community.general.
- Enable the
profile_taskscallback in the Larkspuransible.cfgso every CI run prints per-task timing and you can see which task on the 6-host deploy is the slow one. - Pull external data with the matching
lookupplugin (file,env,aws_secret,password) instead ofcommand/shell, keeping it idempotent and at template time. - Install the collection that carries the filter, test, or callback you need (
community.general) rather than hand-rolling a project-local plugin. - Keep any genuinely custom plugins in the correctly named project directories or a versioned collection, so they load predictably across the team and CI.
- Reach for the plugin layer first when behavior needs changing — output, data pull-in, escalation, ordering — instead of bolting on a shell task that fakes what a plugin already does.
Knowledge Check
What is the defining difference between a module and a plugin in Ansible?
- A module is copied to the managed node and runs there to change state; a plugin runs on the control node and extends Ansible's own behavior
- A module is always written in compiled Python while a plugin is declared purely in YAML markup
- A module always ships bundled with ansible-core, whereas a plugin must always be installed separately by hand
- A module only ever changes files on disk while a plugin only changes running services; the two simply target completely different resource types
You want every CI run to print per-task timing without editing any playbook. What changes that?
- A callback plugin like
profile_tasks— enabling it inansible.cfgre-shapes the run's output for the whole fleet - A lookup plugin that reads per-task timing data back from each managed host at template-evaluation time
- A become plugin that escalates privileges on each managed host so that the timer is able to read the system clocks accurately
- A custom module copied to each managed node that records and returns its own per-task runtime
A play needs a secret from a password store pulled into a variable. What is the clean way to do it?
- A
lookupplugin, which fetches the value on the controller at template time, idempotently and with no shell task - A
commandtask that shells out to the password store's own CLI and then registers its raw stdout into a variable for later use - A custom module that is copied out to each managed node and reads the secret locally on the host
- A strategy plugin that injects the fetched secret into scope before it orders the play's tasks
You wrote a custom Jinja2 filter but the template errors with "unknown filter." What is the most likely cause?
- It isn't in a discovered location — not in a
filter_plugins/directory beside the play, and not namespaced in an installed collection - Custom Jinja2 filters must be compiled to bytecode ahead of time before Ansible is able to load and call them
- Custom filters only ever work from inside module code itself, and never from within ordinary playbook templates
- The filter was evaluated out on the remote managed node, which has no Jinja2 runtime installed there at all to resolve the filter name
You got correct