AnsibleXXVIII · Plugins, Lookups and FiltersPlugins, lookups and filters
Writing a filter plugin (and when not to)
What you'll learn
- Write and load a filter plugin in a collection
- Document it so ansible-doc answers questions about it
- Test it with assertions that run in CI
- Decide whether a given problem is a filter, a module, or neither
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
Occasionally the readable version of an expression does not exist, because the transformation you need is genuinely a small piece of logic rather than a chain of filters. Converting a dotted-quad netmask to a prefix length is a fair example: doable in Jinja, and the result is unreadable.
A filter plugin is about forty lines of Python. This lesson writes one end to end and then spends its second half on the more important question, which is when not to.
The rule first
A filter takes a value and returns a value. It does nothing else.
No file access. No network. No subprocess. No writing anywhere. No reading anything that is not an argument.
That is not style advice, it is a consequence of where filters run and when. A filter is evaluated during templating, on the controller, potentially many times per host, in an order you do not control, with no error handling around it beyond “the task failed”. Anything with a side effect placed there is unpredictable in exactly the way infrastructure automation must not be.
If your idea needs I/O, it is a lookup (controller-side data) or a module (target-side work). Both exist, both are documented, and choosing one of them instead is nearly always the right call.
The filter
Inside a collection, at
plugins/filter/netmask.py:
from __future__ import annotations
DOCUMENTATION = r"""
name: to_cidr
short_description: Convert a dotted-quad netmask to a prefix length
description:
- Returns the CIDR prefix length for a dotted-quad IPv4 netmask.
options:
_input:
description: A dotted-quad netmask such as 255.255.255.0.
type: str
required: true
author:
- Example Corp Platform Team
"""
EXAMPLES = r"""
- name: Show the prefix length
ansible.builtin.debug:
msg: "{{ '255.255.255.0' | example_corp.platform.to_cidr }}"
"""
RETURN = r"""
_value:
description: The prefix length.
type: int
"""
from ansible.errors import AnsibleFilterError
def to_cidr(mask):
try:
octets = [int(o) for o in str(mask).split('.')]
except ValueError:
raise AnsibleFilterError("to_cidr: %r is not a dotted-quad netmask" % (mask,))
if len(octets) != 4 or any(o < 0 or o > 255 for o in octets):
raise AnsibleFilterError("to_cidr: %r is not a dotted-quad netmask" % (mask,))
bits = ''.join(format(o, '08b') for o in octets)
if '01' in bits:
raise AnsibleFilterError("to_cidr: %r is not contiguous" % (mask,))
return bits.count('1')
class FilterModule(object):
def filters(self):
return {'to_cidr': to_cidr}Four things in that file earn their place.
FilterModule.filters() is the entry point. It returns a mapping
of filter name to callable. One file can export several related
filters; do that rather than one file per filter.
AnsibleFilterError is how a filter fails. Raising it produces an
error naming your filter and your message. Raising a bare ValueError
produces a traceback that names Python internals and leaves the user
guessing which expression caused it.
Validation before computation. Three checks — parseable integers, four octets in range, contiguous bits — before the answer. A filter that accepts nonsense and returns a plausible number is worse than one that fails, because the wrong prefix length ends up in a routing configuration.
The DOCUMENTATION block is what makes ansible-doc work. It is
optional to the loader and not optional to your colleagues.
Loading it and proving it loaded
$ ansible-playbook -i localhost, filter-test.ymlTASK [Use the collection filter] ***********************************************
ok: [localhost] => {
"msg": 24
}$ ansible-playbook -i localhost, filter-test.yml[ERROR]: Task failed: Finalization of task args for 'ansible.builtin.debug' failed: Error while resolving value for 'msg': The filter plugin 'example_corp.platform.to_cidr' failed: to_cidr: '255.0.255.0' is not contiguous
Origin: /home/opsuser/project/filter-test.yml:10:14
8 - name: Reject a bad mask
9 ansible.builtin.debug:
10 msg: "{{ '255.0.255.0' | example_corp.platform.to_cidr }}"
^ column 14Filter name, your message, the file, the line and the column. That is
the payoff for AnsibleFilterError and the validation, and it is what
a colleague will see at 3am.
ansible-doc reads the DOCUMENTATION block:
$ ANSIBLE_COLLECTIONS_PATH=./collections ansible-doc -t filter example_corp.platform.to_cidr> FILTER example_corp.platform.to_cidr (/home/opsuser/project/collections/ansible_collections/example_corp/platform/plugins/filter/netmask.py)
Returns the CIDR prefix length for a dotted-quad IPv4 netmask.
OPTIONS (red indicates it is required):
_input A dotted-quad netmask such as 255.255.255.0.
type: str
AUTHOR: Example Corp Platform Team
NAME: to_cidr
EXAMPLES:
- name: Show the prefix length
ansible.builtin.debug:
msg: "{{ '255.255.255.0' | example_corp.platform.to_cidr }}"
RETURN VALUES:
_value The prefix length.
type: intTesting it
A filter is a pure function, which makes it unusually easy to test — no hosts, no connections, no state:
- name: Tests for example_corp.platform.to_cidr
hosts: localhost
gather_facts: false
vars:
cases:
- { mask: '255.255.255.0', expect: 24 }
- { mask: '255.255.255.255', expect: 32 }
- { mask: '0.0.0.0', expect: 0 }
- { mask: '255.255.240.0', expect: 20 }
tasks:
- name: Known-good conversions
ansible.builtin.assert:
that: item.mask | example_corp.platform.to_cidr == item.expect
fail_msg: "{{ item.mask }} gave {{ item.mask | example_corp.platform.to_cidr }}, expected {{ item.expect }}"
success_msg: "{{ item.mask }} -> {{ item.expect }}"
loop: "{{ cases }}"
loop_control:
label: "{{ item.mask }}"$ ansible-playbook -i localhost, filter-tests.ymlok: [localhost] => (item=255.255.240.0) => {
"changed": false,
"item": {
"expect": 20,
"mask": "255.255.240.0"
},
"msg": "255.255.240.0 -> 20"
}
PLAY RECAP *********************************************************************
localhost : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0Add the failure cases too — 255.0.255.0, 256.0.0.0, '' — with
ignore_errors and a check that they did fail, or as a separate play
you expect to fail. A filter’s error handling is the half that gets
broken by a later change.
For anything more substantial, ansible-test units runs pytest
against a collection’s tests/unit/ directory, and a pure function is
the easiest thing in Ansible to unit test. Part XXVI covers the testing
tooling; the point here is that a filter has no excuse for being
untested, because testing it needs nothing.
When not to write one
The boundaries, stated as a table:
| What you need | The right tool | Why not a filter |
|---|---|---|
| Transform a value you already have | filter | — |
| Read a file, an API, an environment variable | lookup | Filters must not do I/O |
| Change something on a managed host | module | Filters run on the controller |
| Change something on the controller | module with delegate_to | Same |
| Decide yes or no about a value | test | A filter returning a boolean works, but reads worse in when: |
| Produce a host list | inventory plugin | Filters cannot be inspected before a run |
Knowledge check
Knowledge check · 4 questions
Q1. Why must a filter plugin avoid file access, network calls and subprocesses?
Q2. What does raising AnsibleFilterError give you that raising ValueError does not?
Q3. Which should be built as something other than a filter? Select all that apply.
Q4. A filter plugin without a DOCUMENTATION block still loads and runs correctly.
Passing score: 75%. Answers are checked in this browser.