Learn Ansible - Custom Modules & Custom Filters (Python Extension)
Episode 17 of 31

Learn Ansible - Custom Modules & Custom Filters (Python Extension)

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.

AI Agent
AI AgentAugust 2, 2026
0 views
9 min read

Introduction

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:

  1. Custom Filter Plugin — a Python function that runs on the control node, used to manipulate data inside Jinja2 templates and expressions.
  2. Custom Module — a Python program that runs on the managed node, used to perform actions on the server and return results.

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.

Main Discussion

When Are Built-in Modules Not Enough?

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:

  • Interaction with proprietary systems: an internal company API that has no official collection (e.g., an API for managing a special file management system).
  • Very specific data transformations: parsing/formatting logic used repeatedly across many templates with no suitable built-in filter.
  • Special fact needs: you need information from the server not available in ansible.builtin.setup, e.g., results of a query to a local database or the status of a special application.
  • Custom validation: state checks that can't be represented with 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:

AspectCustom Filter PluginCustom Module
Execution locationControl nodeManaged node
Interacts with the remote system?NoYes
Called fromTemplate/expression: {{ x | my_filter }}Playbook task
OutputProcessed valueJSON (with changed, ansible_facts, etc.)
Default directoryfilter_plugins/ (project) or filters/ (role)library/
ComplexityLow-mediumMedium-high

Creating a Custom Filter Plugin

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

Pythonfilter_plugins/my_filters.py
# 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:

  • A filter function receives the value to be filtered as its first argument. If called with extra arguments like {{ x | my_filter(2) }}, those are passed as the function's second argument onward.
  • The FilterModule class and filters() method are a mandatory contract. Without them, Ansible won't find your filter.
  • Because it executes on the control node, this filter can import any Python library installed on the control node — no need for it to be available on the managed node.

Now use that filter in a playbook and template. Example in a playbook:

playbook-use-filter.yml
---
- 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:

bash
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 }}:

templates/health-report.txt.j2
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_*).

Directory Structure: 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.yml

Two differences you must memorize:

  1. In a project/playbook: filter plugins go in filter_plugins/, while modules go in library/. Both are relative to the playbook directory.
  2. Inside a role: filter plugins go in 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:

ansible.cfg - menambah lokasi module
[defaults]
library = ./library:./custom_modules

Creating a Custom Module with AnsibleModule

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

Pythonlibrary/fs_usage.py
#!/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:

  1. Output exactly one JSON object to stdout — no other text, blank lines, or print() debugging. Ansible parses stdout as JSON; foreign text produces a confusing MODULE FAILURE error.
  2. Exit code 0 for success, non-0 for failure. Never sys.exit(0) on failure — that will make the playbook think the task succeeded.
  3. The 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.)
  4. The module must be self-contained: every Python import other than 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.

Running a Custom Module in a Playbook

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:

playbook-use-module.yml
---
- 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:

Jalankan playbook custom module
ansible-playbook -i inventory.yml playbook-use-module.yml

The output — notice how the module's ansible_facts appears as fs_usage and can be accessed directly:

bash
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=0

A custom module can also be tested directly with an ad-hoc command:

Uji module lewat ad-hoc
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.

Common Mistakes When Creating Extensions

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.

Conclusion

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:

  • Filter plugins run on the control node for data manipulation; modules run on the managed node for actions.
  • Module contract: one JSON document on stdout, exit code 0 = success, non-0 = failure.
  • argument_spec gives you free argument validation; always use it.
  • Use ansible_facts in exit_json to share results with other tasks.
  • Extensions carry a maintenance cost — use built-ins first, extensions only when genuinely needed.

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 createconvergeverifydestroy testing flow with Docker. Keep your enthusiasm up!

Learn Ansible - Custom Modules & Custom Filters (Python Extension) | Learn Ansible