Developing Ansible Filter and Test Plugins¶
Filter and test plugins add Python logic to the Jinja2 expressions used by
playbooks, roles, and templates. Filters transform data (value | my_filter);
tests answer a boolean question (value is my_test). Both run on the control
node and share one namespace with all other plugins. This document covers
these two plugin types; for action, callback, connection, inventory, lookup,
and vars plugins, see the Ansible
Developing plugins
guide.
Reach for a custom plugin only when the builtin toolset runs out: the Jinja2
builtins (see jinja2.md), ansible.builtin plugins, and plugins
from installed collections cover most needs.
When to Write a Plugin¶
Use a custom filter plugin when a data transformation is complex enough that expressing it in Jinja2 hurts readability or correctness. Treat any of these as the signal:
- It needs more than a couple of chained filters, or
{% set %}variables that only hold intermediate results. - It needs control flow: loops with accumulators, mutation while iterating, nested conditionals, or validation that rejects bad input early.
- It relates structures to each other: it builds a lookup index keyed by an attribute, joins lists on a key, groups and aggregates, or resolves cross-references.
- It applies project-specific semantics: custom merge, deduplication, ordering, or normalization that a single builtin filter cannot express.
- It must fail with a message that names the offending value.
- It is reused across templates or roles, or it is part of a role's public variable contract.
Write a test plugin when the same boolean check is repeated in when:,
assert, or a select-family filter, and the builtin tests cannot express
it. Tests must return a boolean; a transformation that returns any other type
belongs in a filter.
Keep single builtin filters, one-line selectattr / map chains, and Python
method calls on individual values inline. A plugin adds a Python file that
must be loaded, documented, and tested, so pay that cost only when it buys
back readability or reuse.
Filter Plugins¶
A filter plugin is a Python file exposing a FilterModule class whose
filters() method maps names to callables:
# filter_plugins/myapp.py
from ansible.errors import AnsibleFilterError
def listener_index(listeners, key="name"):
"""Build a {key: listener} mapping from a list of listener mappings."""
if not isinstance(listeners, list):
raise AnsibleFilterError(
f"listener_index expects a list, got {type(listeners).__name__}"
)
index = {}
for listener in listeners:
if not isinstance(listener, dict) or key not in listener:
raise AnsibleFilterError(
f"listener_index: entry without '{key}': {listener!r}"
)
index[listener[key]] = listener
return index
class FilterModule(object):
def filters(self):
return {"myapp_listener_index": listener_index}
{% set upstreams_by_name = upstreams | myapp_listener_index %}
proxy_pass http://{{ upstreams_by_name[upstream].host }}:{{ upstreams_by_name[upstream].port }};
- The value on the left of
|is the first argument; additional arguments follow, positional or by keyword. - A filter may return any type, including lists and dicts.
- Several related filters can share one file and one
FilterModuleclass; more than one plugin per file is normal.
Test Plugins¶
A test plugin is a Python file exposing a TestModule class whose tests()
method maps names to callables returning a boolean:
# test_plugins/myapp.py
from ansible.errors import AnsibleFilterError
def online(host):
"""Return True when a single host entry reports online."""
if not isinstance(host, dict):
raise AnsibleFilterError(
f"online expects a mapping, got {type(host).__name__}"
)
return bool(host.get("online", False))
class TestModule(object):
def tests(self):
return {"myapp_online": online}
- name: Report every offline upstream
ansible.builtin.debug:
msg: "{{ item.name }} is offline"
loop: "{{ upstreams | reject('myapp_online') | list }}"
- name: Run only when the primary upstream is online
ansible.builtin.debug:
msg: Primary upstream is online
when: primary_upstream is myapp_online
Loading and Naming¶
- Standalone plugins load from a
filter_plugins/ortest_plugins/directory adjacent to the playbook or inside a role, or from paths configured with thefilter_plugins/test_pluginssettings inansible.cfg($ANSIBLE_FILTER_PLUGINS,$ANSIBLE_TEST_PLUGINS). Adjacent directories must use the_pluginssuffix. - Prefix standalone filter and test names (
myapp_) - they share one namespace with every installed plugin. - Publish shared plugins in a collection under
plugins/filter/andplugins/test/, and call them by FQCN (namespace.collection.name). A role that lives inside a collection cannot carry plugins of its own. - Document collection plugins with a
DOCUMENTATIONblock (or an adjacent YAML file) containingname,short_description,description, andoptions, where_inputdescribes the value on the left of|/is;ansible-doc -t filter <fqcn>andansible-doc -t test <fqcn>then render it.
Dependencies and Environments¶
Plugins import third-party packages from the environment that runs Ansible, not from the project virtual environment; to provide them, extend that environment or run Ansible from the project venv - see installing-ansible.md.
Use a project virtual environment for development only: lint, type checking,
and unit tests. A pyproject.toml with [tool.uv] package = false and
dependency groups fits a collection or playbook repository well:
[dependency-groups]
lint = ["ansible-core>=2.19", "ruff", "ty"]
test = ["ansible-core>=2.19", "pytest"]
ansible-corebelongs in the dev groups becauseuv run pytestanduv run ty checkexecute in the project venv, not in the venv that provides theansiblecommand: pytest importsansible.*, and a type checker needs ansible-core installed to resolveansible.*imports.- Keep
[project].dependenciesempty unless the repository is a real Python package; a collection is not one.
For collections:
galaxy.ymldeclares collection dependencies, andansible-galaxyinstalls those; it does not install Python packages.- To have Ansible Builder install controller-side Python dependencies when
it builds an execution environment, declare them in
requirements.txtat the collection root, or pointmeta/execution-environment.ymlat other files. Collections such asansible.netcommonandamazon.awsship that file. See Collection-level dependencies. - Test-only packages belong in
tests/unit/requirements.txtortests/integration/requirements.txt;ansible-testinstalls them.
Errors and Undefined Values¶
- Raise
AnsibleFilterErrorwith a message that includes the offending value. The genericAnsibleErroralso works when nothing more specific fits. - Do not catch
jinja2.exceptions.UndefinedError; Ansible propagates it asAnsibleUndefinedVariableso undefined variables are fatal only when the value is actually needed. - Raise
AnsibleTypeErrorfor type errors on ansible-core 2.19+;AnsibleFilterTypeErroris deprecated and is removed in ansible-core 2.23. Check the installed Ansible version's error classes when unsure. - Keep plugins free of side effects: Ansible evaluates variables lazily and may re-render a template, so a plugin can run more than once for the same data.
Testing¶
- In a collection, put unit tests under
tests/unit/plugins/filter/ortests/unit/plugins/test/and run them withansible-test units; runansible-test sanityto validate plugin documentation before publishing. - For standalone
filter_plugins/files, import the module in a plainpytesttest - the filter and test functions are ordinary Python. - Exercise the plugin from a scratch playbook: filters in a
debugtask, tests inwhen:and inselect/rejectexpressions.
See also: