Skip to main content
RunBook Academy

AnsibleXXVIII · Plugins, Lookups and FiltersPlugins, lookups and filters

Writing a filter plugin (and when not to)

Advanced⏱ ~22 minansible-playbookansible-docpython3

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

Not yet marked complete on this device.

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:

Read-only / Safeplugins/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

Read-only / Safeit works
$ ansible-playbook -i localhost, filter-test.yml
TASK [Use the collection filter] ***********************************************
ok: [localhost] => {
  "msg": 24
}
Read-only / Safeand it refuses bad input by name
$ 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 14

Filter 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:

Read-only / Safedocumented like any shipped filter
$ 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: int

Testing it

A filter is a pure function, which makes it unusually easy to test — no hosts, no connections, no state:

Read-only / Safefilter-tests.yml
- 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 }}"
Read-only / Safefour cases, no infrastructure
$ ansible-playbook -i localhost, filter-tests.yml
ok: [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=0

Add 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 needThe right toolWhy not a filter
Transform a value you already havefilter
Read a file, an API, an environment variablelookupFilters must not do I/O
Change something on a managed hostmoduleFilters run on the controller
Change something on the controllermodule with delegate_toSame
Decide yes or no about a valuetestA filter returning a boolean works, but reads worse in when:
Produce a host listinventory pluginFilters cannot be inspected before a run

Knowledge check

Knowledge check · 4 questions

  1. Q1. Why must a filter plugin avoid file access, network calls and subprocesses?

  2. Q2. What does raising AnsibleFilterError give you that raising ValueError does not?

  3. Q3. Which should be built as something other than a filter? Select all that apply.

  4. Q4. A filter plugin without a DOCUMENTATION block still loads and runs correctly.

Passing score: 75%. Answers are checked in this browser.