Ansible Custom Filters: Transforming Data In Jinja2 Templates
Ansible Custom Filters: Transforming Data in Jinja2 Templates
If you've spent any serious time with Ansible, you've probably hit the wall where the built-in Jinja2 filters just don't cut it anymore. You're wrestling with some gnarly data transformation — maybe parsing cloud cost tags, normalizing instance metadata, or doing complex string manipulation — and you find yourself writing increasingly convoluted map, select, and regex_replace chains that nobody (including future you) will understand six months from now.
Custom filters are the answer. They let you drop down into Python, write clean transformation logic, and surface it back into your Jinja2 templates as a first-class filter. The result? Templates that are readable, testable, and maintainable.
This guide covers everything: how custom filters work under the hood, how to write them, how to structure them in real projects, and the gotchas that'll bite you if you're not careful.
How Ansible Filter Plugins Actually Work
Before we write a single line of code, let's understand the mechanism. Ansible's Jinja2 environment is extended through a plugin system. When you use a filter like {{ my_var | to_json }}, Ansible looks up to_json in a registry of available filters.
Custom filters tap into this same registry through filter plugins. These are Python modules that implement a FilterModule class with a filters() method that returns a dictionary mapping filter names to Python callables.
Ansible discovers filter plugins by searching in this order:
filter_plugins/directory adjacent to your playbookfilter_plugins/directory in any role being used (roles/myrole/filter_plugins/)- Paths listed in
filter_pluginsunder[defaults]inansible.cfg - The Ansible installation's own filter plugin directory
That search order matters — it means you can override built-in filters (not that you should), and role-level filters are scoped to their collection path.
Your First Custom Filter Plugin
Let's start simple. Here's the bare minimum structure:
# filter_plugins/my_filters.py
class FilterModule(object):
"""Custom Ansible filters."""
def filters(self):
return {
'my_filter_name': self.my_filter_function,
}
def my_filter_function(self, value):
# transform value here
return value
That's it. The FilterModule class name is conventional — Ansible doesn't actually care what you call the class, but stick with the convention. The filters() method is mandatory and must return a dict.
A Real Example: Cost Tag Normalizer
Here's something I actually use in infrastructure automation work. Cloud teams are notoriously inconsistent with resource tagging — you'll get Cost-Center, cost_center, CostCenter, and costcenter all in the same account. Let's write a filter to normalize this:
# filter_plugins/cloud_tags.py
import re
class FilterModule(object):
"""Filters for normalizing and working with cloud resource tags."""
def filters(self):
return {
'normalize_tags': self.normalize_tags,
'tags_to_dict': self.tags_to_dict,
'required_tags_present': self.required_tags_present,
}
def normalize_tags(self, tags, case='lower', separator='_'):
"""
Normalize tag keys to a consistent format.
Args:
tags: dict of tag key/value pairs
case: 'lower' or 'upper'
separator: character to replace spaces and hyphens with
Returns:
dict with normalized keys
"""
if not isinstance(tags, dict):
raise TypeError(f"normalize_tags expects a dict, got {type(tags).__name__}")
normalized = {}
for key, value in tags.items():
# Convert camelCase and PascalCase to snake_case
normalized_key = re.sub(r'(?<!^)(?=[A-Z])', '_', key)
# Replace hyphens and spaces with separator
normalized_key = re.sub(r'[-\s]+', separator, normalized_key)
# Apply case
if case == 'lower':
normalized_key = normalized_key.lower()
elif case == 'upper':
normalized_key = normalized_key.upper()
normalized[normalized_key] = value
return normalized
def tags_to_dict(self, tag_list, key_field='Key', value_field='Value'):
"""
Convert AWS-style tag lists to a plain dict.
AWS returns tags as: [{'Key': 'env', 'Value': 'prod'}, ...]
This converts to: {'env': 'prod', ...}
"""
if not isinstance(tag_list, list):
raise TypeError(f"tags_to_dict expects a list, got {type(tag_list).__name__}")
return {
item[key_field]: item[value_field]
for item in tag_list
if key_field in item and value_field in item
}
def required_tags_present(self, tags, required):
"""
Check if all required tags are present in a tag dict.
Returns list of missing tags (empty list = all present).
"""
if not isinstance(tags, dict):
raise TypeError("required_tags_present expects a dict for tags")
if not isinstance(required, list):
raise TypeError("required_tags_present expects a list for required")
return [tag for tag in required if tag not in tags]
Using it in a playbook:
# playbook.yml
---
- name: Normalize EC2 instance tags
hosts: localhost
vars:
raw_aws_tags:
- Key: "CostCenter"
Value: "Engineering"
- Key: "Environment"
Value: "production"
- Key: "Owner-Team"
Value: "platform-ops"
required_tag_keys:
- cost_center
- environment
- owner_team
tasks:
- name: Process tags
vars:
tag_dict: "{{ raw_aws_tags | tags_to_dict }}"
normalized: "{{ tag_dict | normalize_tags }}"
missing: "{{ normalized | required_tags_present(required_tag_keys) }}"
debug:
msg: |
Normalized tags: {{ normalized }}
Missing required tags: {{ missing }}
Output:
Normalized tags: {'cost_center': 'Engineering', 'environment': 'production', 'owner_team': 'platform-ops'}
Missing required tags: []
Handling Arguments: Positional and Keyword
Filters can accept additional arguments beyond the piped value. In Jinja2, value | filter(arg1, arg2, kwarg=val) maps to filter_function(value, arg1, arg2, kwarg=val) in Python.
def filters(self):
return {
'format_bytes': self.format_bytes,
'truncate_string': self.truncate_string,
}
def format_bytes(self, bytes_value, precision=2, unit=None):
"""
Convert bytes to human-readable format.
Usage:
{{ 1536 | format_bytes }} → "1.50 KB"
{{ 1536 | format_bytes(0) }} → "2 KB"
{{ 1536 | format_bytes(unit='MB') }} → "0.00 MB"
"""
if not isinstance(bytes_value, (int, float)):
raise TypeError(f"format_bytes expects a number, got {type(bytes_value).__name__}")
units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
if unit:
unit = unit.upper()
if unit not in units:
raise ValueError(f"Unknown unit '{unit}'. Valid units: {units}")
idx = units.index(unit)
value = bytes_value / (1024 ** idx)
return f"{value:.{precision}f} {unit}"
# Auto-select unit
value = float(bytes_value)
for u in units:
if abs(value) < 1024.0:
return f"{value:.{precision}f} {u}"
value /= 1024.0
return f"{value:.{precision}f} PB"
def truncate_string(self, value, length=50, suffix='...'):
"""
Truncate a string to a maximum length.
Usage:
{{ long_string | truncate_string(30) }}
{{ long_string | truncate_string(30, suffix=' [more]') }}
"""
if not isinstance(value, str):
value = str(value)
if len(value) <= length:
return value
return value[:length - len(suffix)] + suffix
Template usage:
Instance memory: {{ instance.memory_bytes | format_bytes }}
Instance memory in GB: {{ instance.memory_bytes | format_bytes(2, unit='GB') }}
Description: {{ instance.description | truncate_string(80) }}
Working with Complex Data Structures
Where custom filters really shine is when you're working with nested data. Here's a filter set for working with infrastructure inventory data:
# filter_plugins/inventory_transforms.py
from itertools import groupby
from operator import itemgetter
class FilterModule(object):
"""Filters for transforming infrastructure inventory data."""
def filters(self):
return {
'group_by_key': self.group_by_key,
'flatten_nested': self.flatten_nested,
'merge_defaults': self.merge_defaults,
'extract_costs': self.extract_costs,
}
def group_by_key(self, items, key):
"""
Group a list of dicts by a specific key value.
Input: [{'env': 'prod', 'name': 'web1'}, {'env': 'staging', 'name': 'web2'}, ...]
Output: {'prod': [{'env': 'prod', 'name': 'web1'}], 'staging': [...]}
"""
if not isinstance(items, list):
raise TypeError(f"group_by_key expects a list, got {type(items).__name__}")
result = {}
for item in items:
if not isinstance(item, dict):
continue
group_value = item.get(key, '__undefined__')
if group_value not in result:
result[group_value] = []
result[group_value].append(item)
return result
def flatten_nested(self, data, prefix='', separator='.'):
"""
Flatten a nested dict into dot-notation keys.
Input: {'aws': {'region': 'us-east-1', 'vpc': {'id': 'vpc-123'}}}
Output: {'aws.region': 'us-east-1', 'aws.vpc.id': 'vpc-123'}
"""
result = {}
def _flatten(obj, parent_key=''):
if isinstance(obj, dict):
for key, value in obj.items():
new_key = f"{parent_key}{separator}{key}" if parent_key else key
_flatten(value, new_key)
elif isinstance(obj, list):
for idx, value in enumerate(obj):
new_key = f"{parent_key}{separator}{idx}" if parent_key else str(idx)
_flatten(value, new_key)
else:
result[parent_key] = obj
_flatten(data, prefix)
return result
def merge_defaults(self, config, defaults):
"""
Deep merge a config dict with defaults. Config values take precedence.
Unlike Ansible's combine filter, this handles arbitrary nesting depth.
"""
if not isinstance(defaults, dict):
return config
if not isinstance(config, dict):
return config
result = defaults.copy()
for key, value in config.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = self.merge_defaults(value, result[key])
else:
result[key] = value
return result
def extract_costs(self, instances, cost_field='monthly_cost', group_by=None):
"""
Extract and summarize cost data from a list of instance dicts.
Returns a summary dict with total, average, min, max, and optional grouping.
"""
if not isinstance(instances, list):
raise TypeError("extract_costs expects a list of instance dicts")
costs = [
float(inst.get(cost_field, 0))
for inst in instances
if isinstance(inst, dict)
]
if not costs:
return {'total': 0, 'average': 0, 'min': 0, 'max': 0, 'count': 0}
summary = {
'total': round(sum(costs), 2),
'average': round(sum(costs) / len(costs), 2),
'min': round(min(costs), 2),
'max': round(max(costs), 2),
'count': len(costs),
}
if group_by:
groups = self.group_by_key(instances, group_by)
summary['by_group'] = {
group: round(sum(float(i.get(cost_field, 0)) for i in group_instances), 2)
for group, group_instances in groups.items()
}
return summary
Real-world template usage:
{# Generate cost report by environment #}
{% set cost_summary = ec2_instances | extract_costs('monthly_cost', group_by='environment') %}
Cloud Cost Summary
==================
Total Monthly Cost: ${{ cost_summary.total }}
Instance Count: {{ cost_summary.count }}
Average Cost/Instance: ${{ cost_summary.average }}
Breakdown by Environment:
{% for env, cost in cost_summary.by_group.items() %}
{{ env }}: ${{ cost }}
{% endfor %}
Error Handling and Defensive Programming
This is where a lot of custom filter implementations fall short. In Ansible, a filter that crashes will abort your entire playbook run with a cryptic traceback. Write defensively.
def safe_json_parse(self, value, default=None):
"""
Parse a JSON string, returning default on failure instead of raising.
Usage: {{ possibly_json_string | safe_json_parse({}) }}
"""
import json
if isinstance(value, (dict, list)):
# Already parsed — pass through
return value
if value is None:
return default
try:
return json.loads(str(value))
except (json.JSONDecodeError, ValueError, TypeError):
# Log a warning — Ansible captures these via display
from ansible.utils.display import Display
display = Display()
display.warning(f"safe_json_parse: Could not parse value as JSON, returning default")
return default
Key defensive patterns to follow:
Always type-check inputs:
if not isinstance(value, expected_type):
raise TypeError(f"filter_name expects {expected_type.__name__}, got {type(value).__name__}")
Provide meaningful error messages. Ansible will show the exception message to the user — make it actionable.
Use AnsibleFilterError for expected failures:
from ansible.errors import AnsibleFilterError
def my_filter(self, value):
if not value:
raise AnsibleFilterError(
"my_filter: Input cannot be empty. "
"Check that your variable is defined and non-empty."
)
AnsibleFilterError vs standard exceptions: Use AnsibleFilterError for user-input errors (bad data, missing fields). Use standard Python exceptions for programming errors that indicate a bug in the filter itself.
Testing Your Custom Filters
One of the best things about custom filters is that they're just Python — you can unit test them in isolation without spinning up Ansible at all.
# tests/test_cloud_tags.py
import pytest
import sys
import os
# Add filter_plugins to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'filter_plugins'))
from cloud_tags import FilterModule
@pytest.fixture
def filters():
return FilterModule()
class TestNormalizeTags:
def test_camel_case_to_snake_case(self, filters):
result = filters.normalize_tags({'CostCenter': 'Engineering'})
assert result == {'cost_center': 'Engineering'}
def test_hyphen_to_underscore(self, filters):
result = filters.normalize_tags({'owner-team': 'platform'})
assert result == {'owner_team': 'platform'}
def test_custom_separator(self, filters):
result = filters.normalize_tags({'owner-team': 'platform'}, separator='-')
assert result == {'owner-team': 'platform'}
def test_uppercase_option(self, filters):
result = filters.normalize_tags({'costCenter': 'Eng'}, case='upper')
assert result == {'COST_CENTER': 'Eng'}
def test_raises_on_non_dict(self, filters):
with pytest.raises(TypeError, match="normalize_tags expects a dict"):
filters.normalize_tags(['not', 'a', 'dict'])
class TestTagsToDict:
def test_standard_aws_format(self, filters):
input_tags = [
{'Key': 'env', 'Value': 'prod'},
{'Key': 'team', 'Value': 'platform'},
]
result = filters.tags_to_dict(input_tags)
assert result == {'env': 'prod', 'team': 'platform'}
def test_custom_field_names(self, filters):
input_tags = [{'name': 'env', 'val': 'prod'}]
result = filters.tags_to_dict(input_tags, key_field='name', value_field='val')
assert result == {'env': 'prod'}
def test_skips_malformed_items(self, filters):
input_tags = [
{'Key': 'env', 'Value': 'prod'},
{'missing_key': 'oops'},
]
result = filters.tags_to_dict(input_tags)
assert result == {'env': 'prod'}
Run with pytest tests/ from your playbook directory. This feedback loop is dramatically faster than running a full playbook each time you make a change.
Organizing Filters in Large Projects
For small projects, a single filter_plugins/my_filters.py is fine. For anything serious, organize by domain:
my-ansible-project/
├── filter_plugins/
│ ├── __init__.py # empty, helps with testing imports
│ ├── cloud_tags.py # tag normalization and validation
│ ├── cost_analysis.py # cost calculation and reporting
│ ├── network_utils.py # CIDR, IP, subnet utilities
│ └── string_transforms.py # general string operations
├── tests/
│ ├── test_cloud_tags.py
│ ├── test_cost_analysis.py
│ └── conftest.py
├── playbooks/
└── roles/
For reusable filters across multiple projects, package them as an Ansible Collection. Your filters live in plugins/filter/ within the collection structure, and any project that installs your collection gets them automatically.
Common Pitfalls and How to Avoid Them
Pitfall 1: Mutating the input value
Jinja2 may evaluate templates multiple times. If your filter mutates its input, you'll get subtle bugs.
# BAD — mutates the input list
def bad_add_defaults(self, items, default):
for item in items:
item['default_key'] = default # mutating the original dict!
return items
# GOOD — work on copies
def good_add_defaults(self, items, default):
return [{**item, 'default_key': default} for item in items]
Pitfall 2: Importing heavy modules at module level
Filter plugins are imported when Ansible starts, before any playbook runs. Heavy imports slow down every Ansible execution.
# BAD
import pandas as pd # imported on every ansible run, even unrelated playbooks
class FilterModule(object):
def my_filter(self, value):
# uses pandas
# GOOD — lazy import
class FilterModule(object):
def my_filter(self, value):
import pandas as pd # only imported when filter is actually used
Pitfall 3: Not handling Ansible's Undefined type
When a variable isn't defined in Ansible, it's not None — it's an AnsibleUndefined object. Checking if value: will behave unexpectedly.
from ansible.template import AnsibleUndefined
def safe_filter(self, value, default=''):
# Check for Ansible's Undefined type
if isinstance(value, AnsibleUndefined) or value is None:
return default
return str(value)
Pitfall 4: Forgetting that Jinja2 runs in a sandboxed environment
Ansible's Jinja2 is sandboxed — certain Python builtins and attribute access patterns are restricted. Your filter code itself runs as regular Python (outside the sandbox), so this mainly matters if you're generating code strings to be evaluated, which you shouldn't be doing anyway.
Pitfall 5: Returning non-serializable types
Ansible needs to serialize filter outputs to pass between tasks and store in facts. Always return basic Python types (dict, list, str, int, float, bool, None).
# BAD — returns a set, which isn't JSON-serializable
def unique_values(self, items):
return set(items)
# GOOD
def unique_values(self, items):
return sorted(list(set(items)))
Real-World Scenario: Dynamic Configuration Generation
Here's a practical scenario I've encountered in infrastructure work: generating Nginx upstream configurations from a dynamic list of backend instances, where the config format varies by deployment environment.
# filter_plugins/nginx_config.py
class FilterModule(object):
def filters(self):
return {
'to_nginx_upstream': self.to_nginx_upstream,
'nginx_server_line': self.nginx_server_line,
}
def to_nginx_upstream(self, instances, name='backend',
algorithm='round_robin',
health_check=True):
"""
Convert a list of instance dicts to an nginx upstream block dict.
Each instance should have: ip, port, weight (optional),
max_fails (optional), fail_timeout (optional)
"""
if not isinstance(instances, list):
raise TypeError("to_nginx_upstream expects a list of instance dicts")
algorithm_directives = {
'round_robin': '',
'least_conn': 'least_conn;',
'ip_hash': 'ip_hash;',
'random': 'random;',
}
if algorithm not in algorithm_directives:
raise ValueError(
f"Unknown algorithm '{algorithm}'. "
f"Valid options: {list(algorithm_directives.keys())}"
)
servers = []
for inst in instances:
if not isinstance(inst, dict):
continue
if 'ip' not in inst or 'port' not in inst:
raise ValueError(f"Instance missing required 'ip' or 'port' field: {inst}")
server = {
'address': f"{inst['ip']}:{inst['port']}",
'weight': inst.get('weight', 1),
'max_fails': inst.get('max_fails', 3),
'fail_timeout': inst.get('fail_timeout', '30s'),
}
servers.append(server)
return {
'name': name,
'algorithm_directive': algorithm_directives[algorithm],
'servers': servers,
'health_check': health_check,
}
def nginx_server_line(self, server_dict):
"""Format a single server dict as an nginx server directive string."""
addr = server_dict['address']
weight = server_dict.get('weight', 1)
max_fails = server_dict.get('max_fails', 3)
fail_timeout = server_dict.get('fail_timeout', '30s')
parts = [f"server {addr}"]
if weight != 1:
parts.append(f"weight={weight}")
parts.append(f"max_fails={max_fails}")
parts.append(f"fail_timeout={fail_timeout}")
return ' '.join(parts) + ';'
The Jinja2 template:
{# templates/nginx_upstream.conf.j2 #}
{% set upstream = backend_instances | to_nginx_upstream(
name='app_backend',
algorithm='least_conn',
health_check=true
) %}
upstream {{ upstream.name }} {
{{ upstream.algorithm_directive }}
{% for server in upstream.servers %}
{{ server | nginx_server_line }}
{% endfor %}
{% if upstream.health_check %}
keepalive 32;
{% endif %}
}
Playbook:
- name: Generate nginx upstream config
hosts: loadbalancers
vars:
backend_instances:
- ip: "10.0.1.10"
port: 8080
weight: 2
- ip: "10.0.1.11"
port: 8080
- ip: "10.0.1.12"
port: 8080
max_fails: 5
fail_timeout: "60s"
tasks:
- name: Render nginx upstream config
template:
src: nginx_upstream.conf.j2
dest: /etc/nginx/conf.d/upstream.conf
notify: reload nginx
Performance Considerations
Custom filters run in the Ansible controller process — not on remote hosts. For most use cases this is fine, but a few things to keep in mind:
- Avoid O(n²) operations on large lists. If you're doing set operations, use sets.
- Cache expensive computations if the same filter will be called multiple times with the same input. Use
functools.lru_cacheon the underlying function (not the method itself — use a module-level cached function that the method delegates to). - Profile before optimizing. Ansible's own overhead usually dwarfs filter execution time. I've only ever needed to optimize filter performance when processing inventory data with 10,000+ hosts.
Quick Reference: Filter Plugin Checklist
Before shipping a custom filter, run through this list:
-
FilterModuleclass withfilters()method returning a dict - Input type validation with clear error messages
- Handles
Noneand empty inputs gracefully - Does not mutate input arguments
- Returns only JSON-serializable types
- Heavy imports done lazily (inside the function)
- Unit tests covering happy path and edge cases
- Docstring explaining usage with examples
- Added to version control (filter plugins are code, treat them like it)
Wrapping Up
Custom Ansible filters are one of those tools that seem like a nice-to-have until you start using them seriously — then they become indispensable. They let you keep your templates readable, move complex logic somewhere testable, and build a library of reusable transformations that your whole team benefits from.
The pattern is simple: messy data goes in, clean data comes out, and your YAML stays sane. Start with one domain (tags, costs, networking — whatever you're wrangling most), write the filters, write the tests, and you'll never want to go back to deeply nested map and selectattr chains.
The code examples in this article are production-ready starting points. Fork them, adapt them to your environment, and build something useful. Your future self — and your CFO — will thank you.
Was this article helpful?
Related Articles
Ansible Dynamic Inventory: Automating Cloud Infrastructure
Use dynamic inventories to automatically discover and manage cloud infrastructure — AWS EC2, Azure VMs, and GCP instances with Ansible inventory plugins.
Ansible Fundamentals: Your First Playbook to Production
Learn Ansible from scratch — install Ansible, write your first playbook, understand modules, manage inventories, and automate server configuration.
Ansible Roles and Galaxy: Structuring Automation at Scale
Structure your Ansible automation with roles for reusability. Learn role directory structure, Galaxy usage, dependencies, and testing with Molecule.
Ansible Vault: Encrypting Secrets in Your Automation
Encrypt sensitive data with Ansible Vault — encrypt files, inline variables, multi-password setups, and integrate with external secret managers like HashiCorp Vault.
Ansible Handlers: Triggering Service Restarts Only When Configuration Changes
If you've spent any time managing infrastructure with Ansible, you've probably run into this scenario: you're deploying a configuration change to nginx acr...
Ansible Tags: Selective Task Execution For Faster Deployments
If you've ever run a full Ansible playbook just to push a single config change and watched it grind through 47 tasks before touching the one you care about...
More in Ansible
View all →Fix Ansible 'Permission Denied' on Become (Sudo)
Resolve Ansible become/sudo permission denied errors — fix sudoers config, passwordless sudo, and become method settings.
Fix Ansible 'Unreachable' Host Errors
Resolve Ansible unreachable host failures — diagnose SSH connectivity, key auth, Python interpreter, and timeout issues.