In this episode we'll learn to extend Ansible with Python: creating custom filter plugins for data manipulation in Jinja2, creating custom modules with the AnsibleModule library, and understanding the module contract with Ansible.

After episode 16, where we covered asynchronous actions & polling, you now know how to handle long tasks elegantly. But there's one theme we've silently relied on all along: everything you use in Ansible — modules like ansible.builtin.copy, apt, filters like regex_replace, combine — is Python code. Previous episodes were about consuming what's available. In episode 17, it's our turn to produce.
There are times when built-in modules aren't enough. Imagine working with an internal application that has a proprietary configuration format, or an internal API that has no dedicated Ansible module. Or imagine needing to transform data in a way that has no built-in filter — for example, formatting byte size as human-readable "1.2 GB", used across 20 different templates. Rewriting the same logic over and over isn't a solution; that's when you write a Python extension.
Ansible provides two main "extension surfaces" that we'll cover:
Understanding this difference in execution location is very important. Filters execute where templating happens (control node), while modules execute where action happens (managed node). This is the episode where your Python programming skills truly start to shine — and it's also the gateway to the next episode about testing Ansible code.
Before writing your own code, ask first: is this genuinely a problem that built-in modules can't solve? Writing an extension carries its own maintenance cost, so we must be selective. Some valid indicators:
ansible.builtin.setup, e.g., results of a query to a local database or the status of a special application.when + facts.Conversely, don't write a custom module for something that already exists. If you find yourself wanting to build a "restart nginx" or "install packages" module, stop — those already exist. Rule of thumb: use built-ins first, extensions only for what's genuinely unavailable.
The fundamental difference between the two extension types:
| Aspect | Custom Filter Plugin | Custom Module |
|---|---|---|
| Execution location | Control node | Managed node |
| Interacts with the remote system? | No | Yes |
| Called from | Template/expression: {{ x | my_filter }} | Playbook task |
| Output | Processed value | JSON (with changed, ansible_facts, etc.) |
| Default directory | filter_plugins/ (project) or filters/ (role) | library/ |
| Complexity | Low-medium | Medium-high |
Filter plugins are the cheapest and most commonly used way to extend Ansible. A filter is just a regular Python function that receives a value as the first argument (and optional extra arguments), then returns the processed result. The only "structural" requirement is defining a FilterModule class with a filters() method returning a dictionary mapping filter names → functions.
Let's create two filters often needed in the real world: human_size (formatting bytes as "1.5 GB") and mask_ip (hiding part of an IP for logging/monitoring purposes):
# filter_plugins/my_filters.py
# Custom Ansible filter plugin that runs on the control node.
def human_size(num_bytes):
"""Convert a byte count into a human-readable string."""
units = ["B", "KB", "MB", "GB", "TB", "PB"]
value = float(num_bytes)
for unit in units:
if value < 1024 or unit == units[-1]:
return f"{value:.1f} {unit}"
value /= 1024
def mask_ip(address):
"""Hide the last two octets of an IPv4 address."""
parts = address.split(".")
if len(parts) != 4:
return address
return ".".join(parts[:2] + ["*", "*"])
class FilterModule:
"""List of filters registered to Ansible."""
def filters(self):
return {
"human_size": human_size,
"mask_ip": mask_ip,
}There are a few important things here:
{{ x | my_filter(2) }}, those are passed as the function's second argument onward.FilterModule class and filters() method are a mandatory contract. Without them, Ansible won't find your filter.Now use that filter in a playbook and template. Example in a playbook:
---
- name: Gunakan custom filter plugin
hosts: webservers
tasks:
- name: Format ukuran file konfigurasi
ansible.builtin.debug:
msg: "Ukuran konfigurasi = {{ 153391689 | human_size }}"
- name: Masking IP untuk log
ansible.builtin.debug:
msg: "IP server dilaporkan sebagai {{ '103.42.77.19' | mask_ip }}"Its execution result:
TASK [Format ukuran file konfigurasi] **************************************
ok: [web01] => {
"msg": "Ukuran konfigurasi = 146.3 MB"
}
TASK [Masking IP untuk log] ***********************************************
ok: [web01] => {
"msg": "IP server dilaporkan sebagai 103.42.*.*"
}Filters can also be used inside the .j2 templates we learned about in episode 8, or combined with variables and facts — e.g., {{ ansible_facts['memtotal_mb'] | human_size }}:
Server: {{ ansible_facts['hostname'] }}
Memory: {{ ansible_facts['memtotal_mb'] * 1024 * 1024 | human_size }}
Client IP (masked): {{ ansible_facts['default_ipv4']['address'] | mask_ip }}Tip
Filter names must not collide with built-in Ansible/Jinja2 filters (like default, lower, map). If you override a built-in name, its behavior could change across all project templates — a very hard bug to trace. Use a project name prefix (e.g., myco_*).
filter_plugins/ vs library/The extension's location determines where Ansible looks for it. Here's the correct structure at project level:
ansible-extension/
├── ansible.cfg
├── inventory.yml
├── filter_plugins/
│ └── my_filters.py
├── library/
│ └── fs_usage.py
└── playbook-extension.ymlTwo differences you must memorize:
filter_plugins/, while modules go in library/. Both are relative to the playbook directory.filters/ (not filter_plugins/!), while modules still go in library/. Putting filters in roles/<role>/filter_plugins/ is a classic mistake that makes the filter "not found".For modules, Ansible searches several locations. You can also specify additional paths via ansible.cfg:
[defaults]
library = ./library:./custom_modulesAnsibleModuleNow the more serious part: custom modules. A module is a self-contained Python program run on the managed node. It receives arguments from the task (in JSON form), does its work, then prints exactly one JSON document to stdout and exits with a certain exit code. On top of that, the module uses the AnsibleModule class provided by ansible.module_utils.basic — at runtime, ansible-core wraps our module with the "AnsiballZ wrapper" that injects this AnsibleModule code, so we don't need to install anything on the managed node.
Here's a useful, realistic module example: fs_usage — calculating disk usage percentage for a path, returning it as facts, and failing with a clear message if it exceeds a threshold:
#!/usr/bin/python
# library/fs_usage.py
"""Custom module for checking disk usage on a path."""
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import shutil
from ansible.module_utils.basic import AnsibleModule
DOCUMENTATION = r"""
---
module: fs_usage
short_description: Check disk usage and fail if it exceeds a threshold
description:
- Calculate the disk usage percentage on a given path.
- Return the result as ansible_facts (fs_usage).
- Fail (fail_json) if usage exceeds the specified threshold.
options:
path:
description: The directory path to check.
required: true
type: str
threshold:
description: Usage percentage considered critical (0-100).
required: false
type: int
default: 80
"""
EXAMPLES = r"""
- name: Cek penggunaan disk /var
fs_usage:
path: /var
threshold: 85
"""
def main():
module = AnsibleModule(
argument_spec=dict(
path=dict(type="str", required=True),
threshold=dict(type="int", default=80),
),
supports_check_mode=False,
)
path = module.params["path"]
threshold = module.params["threshold"]
usage = shutil.disk_usage(path)
used_percent = (usage.used / usage.total) * 100
result = dict(
path=path,
total=usage.total,
used=usage.used,
free=usage.free,
used_percent=round(used_percent, 1),
threshold=threshold,
)
if used_percent >= threshold:
module.fail_json(
msg="Penggunaan disk melebihi threshold!",
**result,
)
module.exit_json(
changed=False,
ansible_facts={"fs_usage": result},
)
if __name__ == "__main__":
main()Let's break down every important part:
argument_spec: the declaration of arguments the module accepts. path is str type and required (required=True); threshold is int type with a default of 80. Ansible automatically validates argument types and presence — if a task calls without path, the module rejects it before main() even runs. For boolean values, remember: use type="bool", not the string "true"/"false".module.params: a dictionary containing the validated argument values.module.exit_json(...): the success signal. The module exits with code 0 and the result JSON is printed to stdout. Putting the result in ansible_facts makes it available as the fs_usage variable for subsequent tasks.module.fail_json(...): the failure signal. The module exits with a non-zero code, the playbook reports FAILED, and the msg message shows in the output.The module contract every custom module must follow:
print() debugging. Ansible parses stdout as JSON; foreign text produces a confusing MODULE FAILURE error.0 for success, non-0 for failure. Never sys.exit(0) on failure — that will make the playbook think the task succeeded.DOCUMENTATION docstring (and ideally EXAMPLES, RETURN) makes the module readable by ansible-doc and by humans. (Note: ANSIBLE_METADATA used to be required, but has been removed from modern ansible-core.)AnsibleModule must be available on the managed node, because the module executes there. If you import an external library, make sure it's installed on the target server — this is the difference from filters that run on the control node.Warning
Never use print() for debugging inside a module. All stdout output is considered the module's JSON result. For debugging, use module.log(...) or write to module.debug / a log file on the remote.
Because this module is in the library/ directory alongside the playbook, Ansible finds it automatically and we can use it like a regular module — just with its short name fs_usage:
---
- name: Gunakan custom module fs_usage
hosts: all
become: true
tasks:
- name: Cek penggunaan disk /var
fs_usage:
path: /var
threshold: 85
register: disk_status
- name: Tampilkan hasil jika sehat
ansible.builtin.debug:
msg: "Disk /var terpakai {{ disk_status.fs_usage.used_percent }}%"
rescue:
- name: Beri peringatan jika disk melebihi threshold
ansible.builtin.debug:
msg: "DISK KRITIS: {{ ansible_failed_result.msg }} - path {{ ansible_failed_result.path }}"Run the playbook:
ansible-playbook -i inventory.yml playbook-use-module.ymlThe output — notice how the module's ansible_facts appears as fs_usage and can be accessed directly:
TASK [Cek penggunaan disk /var] ********************************************
ok: [web01]
TASK [Tampilkan hasil jika sehat] ******************************************
ok: [web01] => {
"msg": "Disk /var terpakai 41.7%"
}
PLAY RECAP *****************************************************************
web01 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0A custom module can also be tested directly with an ad-hoc command:
ansible all -m fs_usage -a "path=/tmp threshold=95"Note
Combine custom modules with the error handling patterns from episode 10: the block/rescue structure is very well suited for catching fail_json from a custom module — like the example playbook above that handles a critical disk status.
Here's a summary of the traps that most often haunt developers new to writing Ansible extensions:
1. The module prints non-JSON output to stdout. print(), banner messages, or console logging will break JSON parsing → MODULE FAILURE error. Use module.log() for logging.
2. Forgetting the FilterModule class. A Python filter without the FilterModule class with a filters() method will never be registered. Ansible silently ignores it.
3. Filters/roles placed in the wrong directory. Inside a role, filters must go in filters/, not filter_plugins/. Role modules go in library/, the same as a project.
4. Module not found ("couldn't resolve module"). Make sure the module is in a directory Ansible traverses (./library, the ansible.cfg library =, or the ANSIBLE_LIBRARY env var), and check with ansible-doc -l | grep fs_usage or ansible <host> -m fs_usage to test.
5. Type errors in argument_spec. required=True without an argument → a validation error before execution. Booleans must be type="bool". Integers must be type="int", not strings.
6. Wrong exit code. sys.exit(0) on failure makes Ansible think the task succeeded. Always use module.fail_json() for failures.
7. Imports not present on the managed node. Modules execute on the remote; if the module imports a library not installed there, execution fails. Filters (control node) don't have this problem.
8. Rewriting what already exists. Checking built-in and community collections (remember episode 13: ansible-galaxy collection search) before writing a custom module saves a lot of time.
In episode 17, we opened up Ansible's "engine" and saw that its extensibility is pure Python. You learned to recognize when built-in modules are no longer enough, create simple custom filter plugins (human_size, mask_ip) that run on the control node, understand the filter_plugins/ and library/ directory structure (along with the filters/ variant inside roles), and create the fs_usage custom module using the AnsibleModule library — complete with argument_spec, exit_json, fail_json, and the strict module contract (one JSON on stdout + the correct exit code).
Key points to take home:
argument_spec gives you free argument validation; always use it.ansible_facts in exit_json to share results with other tasks.With the ability to create extensions, your Ansible code can now be as complex as needed. But with that power comes responsibility: complex code must be tested, and best practices must be enforced. In episode 18, we'll cover Code Quality Testing with ansible-lint & Molecule — how to standardize, lint, and test Ansible playbooks and roles automatically, including the create → converge → verify → destroy testing flow with Docker. Keep your enthusiasm up!