Writing Custom Ansible Filter Plugins in Python With AI
Turn unreadable Jinja2 one-liners into clean, testable Ansible filter plugins in Python — with AI scaffolding the code and tests while you review every line.
- #iac
- #ansible
- #python
- #jinja2
- #automation
I still remember the pull request that broke me. A teammate had committed a single Jinja2 line inside a set_fact task that wrapped through three map, two selectattr, a rejectattr, and a regex_replace whose backslashes had been escaped so many times I genuinely could not tell what it matched. It worked. Nobody could explain why. When the upstream data shape changed six weeks later, it silently produced an empty string, and a production deploy shipped a config file with a blank database host.
That expression should never have been a Jinja one-liner. It should have been a filter plugin: a small, named, unit-tested Python function with a real test suite around it. This post is about exactly that — writing custom Ansible filter plugins in Python, when to reach for one, and how AI turns the boilerplate into a thirty-second job while you keep your hands on the wheel.
When a Filter Beats a Jinja One-Liner
Jinja2 is great for light shaping — pick a key, default a value, join a list. The moment your expression needs branching logic, error handling, or more than one transformation step, you have crossed into “this should be Python” territory.
Reach for a filter plugin when:
- The logic is non-trivial and you want to read it as ordinary code, not a chain of pipes.
- You need real error messages when input is malformed, instead of a cryptic
'dict object' has no attributetraceback. - The transformation is reused across roles and you want one source of truth.
- You want to unit test it. A filter is just a pure function — easy to test in isolation, impossible to test when it lives inline in a playbook.
A filter takes a value on the left of the pipe ({{ value | my_filter }}) and returns a new value. That is the whole contract. Keep them pure: input in, value out, no side effects.
The FilterModule Class
Ansible discovers filters through a class named FilterModule that exposes a filters() method returning a dict of {filter_name: callable}. That is the only structural requirement.
# filter_plugins/text_utils.py
from ansible.errors import AnsibleFilterError
def redact_secrets(value, patterns=None):
"""Mask secret-looking substrings in a log line."""
import re
if not isinstance(value, str):
raise AnsibleFilterError(
"redact_secrets expects a string, got %s" % type(value).__name__
)
default_patterns = [
r"(?i)(password|passwd|secret|token|api[_-]?key)\s*[=:]\s*\S+",
r"AKIA[0-9A-Z]{16}", # AWS access key id
]
for pattern in (patterns or default_patterns):
value = re.sub(pattern, "***REDACTED***", value)
return value
class FilterModule(object):
"""Custom text filters for our playbooks."""
def filters(self):
return {
"redact_secrets": redact_secrets,
}
Notice the function lives at module scope and the class is a thin registry pointing at it. That separation is deliberate: the pure function is what your tests import directly, with no Ansible machinery involved.
Where to Put It
Ansible looks for filter plugins in a few well-defined places, and getting this wrong is the single most common reason a brand-new filter throws template error while templating string.
- Adjacent to a playbook: a
filter_plugins/directory next to the playbook or in a role’sfilter_plugins/. Fast for one-off project filters. - In a collection (the modern, shippable way):
plugins/filter/inside the collection, e.g.ansible_collections/mynamespace/mycoll/plugins/filter/text_utils.py. You then call it fully qualified asmynamespace.mycoll.redact_secrets, which avoids name clashes. - A path on
filter_pluginsinansible.cfgif you want a shared central directory.
Pro Tip: prefer a collection’s plugins/filter/ for anything you will reuse. The filter_plugins/ directory is convenient but unscoped — two roles defining a to_env filter will quietly shadow each other, and you will lose an afternoon to it.
Accepting Arguments
A filter can take arguments after the value. They arrive as ordinary positional or keyword args. Here is a to_semver normalizer and a dict-to-env-file converter that both take options:
# filter_plugins/structured.py
import re
from ansible.errors import AnsibleFilterError
_SEMVER = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)")
def to_semver(value, strict=False):
"""Normalize a version string to bare MAJOR.MINOR.PATCH."""
if not isinstance(value, str):
raise AnsibleFilterError("to_semver expects a string")
match = _SEMVER.match(value.strip())
if not match:
if strict:
raise AnsibleFilterError("'%s' is not a valid semver" % value)
return "0.0.0"
return "%s.%s.%s" % match.groups()
def to_env(value, prefix="", upper=True):
"""Convert a dict into a list of KEY=value env-file lines."""
if not isinstance(value, dict):
raise AnsibleFilterError("to_env expects a dict")
lines = []
for key, val in value.items():
name = "%s%s" % (prefix, key)
if upper:
name = name.upper()
lines.append("%s=%s" % (name, val))
return lines
class FilterModule(object):
def filters(self):
return {
"to_semver": to_semver,
"to_env": to_env,
}
Raising AnsibleFilterError on bad input is what separates a professional filter from a footgun. It gives the operator a clear, attributable message instead of a Python traceback buried in templating internals — exactly the failure mode that bit me in that production deploy.
Using It in Templates and Tasks
Once discovered, a filter is available everywhere Jinja is. In a task:
- name: "Pin the app to a normalized version"
ansible.builtin.set_fact:
app_version: "{{ raw_release_tag | to_semver(strict=True) }}"
- name: "Scrub secrets before shipping a debug log"
ansible.builtin.copy:
content: "{{ app_log | redact_secrets }}"
dest: "/var/log/app/sanitized.log"
In a Jinja template (env.j2):
{% for line in service_config | to_env(prefix='APP_') %}
{{ line }}
{% endfor %}
Quote any YAML value containing a colon — "{{ raw_release_tag | to_semver(strict=True) }}" — or the YAML parser will try to read it as a mapping and fail before Ansible ever sees your filter.
Filters vs. Tests vs. Lookups
It is worth knowing where filters sit among Ansible’s plugin types, because AI will happily generate the wrong one if you are vague:
- Filter plugins transform a value:
{{ x | my_filter }}. Return any type. - Test plugins answer a yes/no question:
{{ x is my_test }}. They register via aTestModuleclass with atests()method and must return a boolean. - Lookup plugins fetch data from outside — files, a vault, an API — and run on the controller during templating:
{{ lookup('my_lookup', 'arg') }}.
If your function returns a boolean and reads naturally as an adjective (“is even”, “is expired”), it wants to be a test, not a filter. If it reaches out to fetch something, it is a lookup.
How AI Scaffolds the Plugin and Its Tests
This is where an assistant earns its keep. The structure is rote, so describe the function in plain English and let the model produce both the plugin and a pytest suite. A prompt like “Write an Ansible filter plugin to_env that turns a dict into KEY=value lines with an optional prefix, raises AnsibleFilterError on non-dict input, and give me pytest cases covering the prefix, the error path, and an empty dict” will get you a near-complete starting point.
Because the core is a pure function, the tests need no Ansible at all:
# tests/test_structured.py
import pytest
from filter_plugins.structured import to_env, to_semver
from ansible.errors import AnsibleFilterError
def test_to_env_prefixes_and_uppercases():
assert to_env({"host": "db1"}, prefix="app_") == ["APP_HOST=db1"]
def test_to_env_rejects_non_dict():
with pytest.raises(AnsibleFilterError):
to_env(["not", "a", "dict"])
def test_to_semver_strips_v_prefix():
assert to_semver("v1.2.3") == "1.2.3"
def test_to_semver_strict_raises_on_garbage():
with pytest.raises(AnsibleFilterError):
to_semver("not-a-version", strict=True)
Tools like Claude, Cursor, or GitHub Copilot are excellent at this kind of bounded, well-specified generation. Keep a reusable scaffold prompt in your prompt library so every new filter starts from the same shape — class registry, pure function, error handling, three test cases minimum. If you write a lot of these, a curated prompt pack for Ansible plugin work pays for itself quickly.
Keep the Human in the Loop
AI here is a fast, tireless junior engineer — not a peer who gets to merge. Treat its output accordingly:
- Review every change. The model will confidently regex something subtly wrong. Read the function before you trust it, and run it through a code review pass the way you would any contributor’s PR.
- Always run check-mode first.
ansible-playbook --check --diffbefore a real run. A filter that returns the wrong type can cascade into a config file full ofNone. - Never hand AI your vault keys. Generation happens against descriptions and fixtures, never live secrets.
- A filter that touches secrets gets extra scrutiny. The
redact_secretsexample above is genuinely security-sensitive — a missed pattern means credentials in a log you thought was clean. Review the regex set by hand, add a test for every secret format you care about, and never assume the AI’s default patterns are exhaustive.
The whole point of a filter plugin is legibility and testability. That benefit evaporates if you let the assistant write logic nobody on the team actually understands.
Conclusion
The unreadable Jinja line that started my morning years ago would have been twelve lines of plain Python with four tests — and that blank database host would have been a red test, not a production incident. Filter plugins are one of the cheapest reliability upgrades in Ansible, and AI removes nearly all of the friction in writing them. Describe the transform, let the model scaffold the function and its tests, then do the one thing the model cannot: read it, test it in check-mode, and own it. Browse more Infrastructure as Code guides when you are ready for the next one.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.