If you have used Ansible for any length of time, you have hit the wall where the built-in modules almost solve your problem but not quite. Maybe you need to parse a Cisco IOS show running-config for hostname, serial number, and privileged users in one pass. Maybe you have a vendor REST API that returns nested JSON no uri task can clean up. Maybe you want a single, idempotent task that wraps three CLI calls so your playbook is not a Jinja jungle.
That is exactly what custom Ansible modules in Python are for. A custom module is a small Python script that uses the AnsibleModule class to accept arguments from a playbook, do work on the target (or the control node), and return JSON back. When it is in the right directory or packaged in a collection, you call it from a play just like ansible.builtin.copy or any other shipped module.
This guide walks through every part of writing a production-quality custom Ansible module in 2026: the directory layout Ansible actually scans, the AnsibleModule skeleton you cannot skip, idempotency and check-mode patterns, how to test and debug locally, the errors that break new modules every time, and how to package the result into a collection that works inside AAP, AWX, and Execution Environments.
What we will cover:
Before you write any Python, it helps to see how a custom module compares to the other ways Ansible lets you extend itself. A lot of “I need a custom module” problems are actually filter-plugin or role problems in disguise.
| Extension type | What it is | Runs on | Use when |
|---|---|---|---|
| Custom module | Standalone Python script using AnsibleModule |
Target node (or delegate_to: localhost) |
You need a new task verb with idempotent behavior |
| Action plugin | Python class that runs on the controller before/after a module | Control node | You need controller-side logic that wraps a module |
| Filter plugin | Python function exposed to Jinja2 | Control node (template eval) | You only need to transform variables |
| Lookup plugin | Python function that returns data into a variable | Control node | You need to read external data into a play |
| Role | YAML + tasks + templates bundled together | Wherever the tasks run | You are composing existing modules, not writing new logic |
| Collection | Distributable bundle of modules, plugins, roles | Whatever they target | You want to share or version several of the above |
A custom module is the right answer when you need a new verb in a playbook, with arguments, return values, and idempotent behavior. If you only need to massage a string, write a filter plugin. If you only need to pull data into a variable, write a lookup plugin. If you only need to chain three existing modules with some logic, write a role.
If you are running Ansible in a managed lab environment because production is too risky to test custom Python against, CloudMyLab’s hosted Ansible labs give you a safe-to-fail environment with the platform already wired up. Team plans land in the $2K–$5K initial / $45K 3-year TCO range, well below the $50K–$100K of a comparable on-premise lab build. You are not paying for Ansible itself. You are paying to skip the week of Execution Environment and inventory plumbing that usually comes with it.
An Ansible custom module is a Python script that imports the AnsibleModule helper class, parses a known set of arguments, performs an action, and returns a JSON document Ansible can read. To Ansible, your custom script is indistinguishable from a built-in module like ansible.builtin.file or cisco.ios.ios_command. To Python, it is a regular script with a main() function and an if __name__ == '__main__': main() guard.
Two things make a module different from a normal Python script:
AnsibleModule(argument_spec=...) to declare the inputs the playbook can pass in.module.exit_json(...) on success or module.fail_json(...) on failure, never with print() or return.Everything else (regex parsing, REST calls, file I/O, network connections) is standard Python. There is no special framework to learn. The discipline is in the input and output contract, not the logic in between.
Custom modules are particularly useful in network automation when you need to slice complex show output, transform vendor-specific data structures, validate configurations against a baseline, or wrap multi-step CLI workflows so playbooks stay readable. For background on the broader landscape of Ansible module types and how they fit together, see Ansible Modules Types Explained.
Most engineers write a custom module for one of three honest reasons: a built-in module does 80% of what they need, the remaining 20% requires real Python, or the team has a recurring task they want to express as a single playbook verb. The wrong reasons are equally common. Writing a module to glue together tasks you could put in a role, or to do data transformation a Jinja filter would handle, leaves you with code to maintain without a real benefit.
A custom module is the right tool when you need:
set_fact and Jinja templates without becoming unreadableansible-lint and ansible-testparse_cli_textfsm template captures cleanlyA custom module is the wrong tool when:
ansible.builtin.uri, ansible.builtin.set_fact, and a Jinja templateIf you are not sure, write the playbook first using built-in modules. If the playbook ends up with three nested set_fact calls, a loop over register results, and a filter you have to apologize for, that is the moment a module saves you. For broader playbook structure guidance, see Best Practices for Writing Efficient Ansible Playbooks.
Ansible looks for custom modules in a few predictable places. The simplest layout, and the one you should reach for first, is a library/ directory next to your playbook. Anything inside that directory becomes available to plays in the same project without extra configuration.
your_project/
├── ansible.cfg
├── inventory.ini
├── playbook.yml
└── library/
└── command_parser.py
When command_parser.py lives in library/, you call it from playbook.yml as command_parser: with whatever arguments it expects. No collections: line, no import_role, no plumbing.
If you have several playbooks that share the same modules, set a global library path in ansible.cfg:
[defaults]
library = ~/ansible_modules:./library
inventory = ./inventory.ini
You can also point Ansible at a directory using the ANSIBLE_LIBRARY environment variable. Both approaches work, but they are second-best to a collection once you have more than two or three modules. Collections version cleanly, install through ansible-galaxy, and slot directly into Execution Environments. The library/ directory is fine for prototyping; it is not where production code should live long-term.
A few rules that catch new module authors every time:
command_parser.py is called command_parser: in the playbook.command-parser.chmod +x does not hurt.library/ automatically. Subdirectories inside library/ are not scanned the same way; use a collection if you want nesting.Every custom module follows the same shape. Get the skeleton right and the rest is just whatever Python you need in the middle.
#!/usr/bin/env python3
from ansible.module_utils.basic import AnsibleModule
def run_module():
# 1. Declare the arguments the playbook can pass in
module_args = dict(
running_config=dict(type='list', required=True, elements='str'),
match_users=dict(type='bool', required=False, default=True),
)
# 2. Build the AnsibleModule instance
module = AnsibleModule(
argument_spec=module_args,
supports_check_mode=True,
)
# 3. Default result dict
result = dict(
changed=False,
output={},
)
# 4. Short-circuit if the user passed --check
if module.check_mode:
module.exit_json(**result)
# 5. Real work goes here. Wrap it in try/except so failures
# return JSON, not Python tracebacks.
try:
config_lines = module.params['running_config']
result['output'] = {'line_count': len(config_lines)}
except Exception as exc:
module.fail_json(msg='Module failed', exception=str(exc))
# 6. Return JSON
module.exit_json(**result)
def main():
run_module()
if __name__ == '__main__':
main()
Six pieces matter here:
argument_spec is the contract between your playbook and your code. Each key declares an argument name, its type (str, list, bool, int, dict, path), whether it is required, and an optional default. elements='str' tells Ansible the list contains strings, which is required for newer versions and prevents type warnings.
AnsibleModule(argument_spec=..., supports_check_mode=True) is the helper object. It parses arguments, validates types, handles --check and --diff, and gives you module.params, module.exit_json(), and module.fail_json().
module.params is a dictionary of the parsed arguments. Always read inputs from module.params['name'], never from sys.argv.
module.check_mode is True when the user runs the play with --check. Custom modules that change state must return changed=True if they would have changed something, and must not actually make the change.
module.exit_json(**result) succeeds and returns the result dict to Ansible. The dict can include any keys; changed is the only required one.
module.fail_json(msg=...) fails the task. Always pass msg. Optionally include exception= for tracebacks, but never let an unhandled Python exception escape the module — it produces a wall of traceback in the playbook output and breaks structured error handling.
This skeleton is the baseline for every example below.
Here is a complete, working custom module that takes a Cisco IOS show running-config (as a list of lines passed in from a previous task), and returns the hostname, domain name, product ID, serial number, and configured usernames. It is the kind of one-pass parsing job built-in modules cannot do cleanly and Jinja filters cannot do at all.
The playbook that calls it:
---
- name: IOS command parser
hosts: r3
gather_facts: false
tasks:
- name: Capture running-config
cisco.ios.ios_command:
commands: show running-config
register: sh_command
- name: Parse the running-config
command_parser:
running_config: ""
register: parser_output
- name: Show parsed values
ansible.builtin.debug:
msg: ""
The module file at library/command_parser.py:
#!/usr/bin/env python3
from ansible.module_utils.basic import AnsibleModule
import re
def parse_running_config(lines):
"""Pull structured data out of a Cisco IOS running-config."""
output = "\n".join(lines)
parsed = {}
hostname_pattern = re.compile(r'^hostname (\S+)', re.MULTILINE)
domain_pattern = re.compile(r'^ip domain[- ]name (\S+)', re.MULTILINE)
pid_pattern = re.compile(r'license udi pid (\S+) sn (\S+)')
username_pattern = re.compile(r'^username (\S+) privilege (\d{1,2})', re.MULTILINE)
if (match := hostname_pattern.search(output)):
parsed['hostname'] = match.group(1)
if (match := domain_pattern.search(output)):
parsed['domain'] = match.group(1)
if (match := pid_pattern.search(output)):
parsed['product_id'] = match.group(1)
parsed['serial_number'] = match.group(2)
parsed['users'] = [
{'name': m.group(1), 'privilege': int(m.group(2))}
for m in username_pattern.finditer(output)
]
return parsed
def run_module():
module_args = dict(
running_config=dict(type='list', required=True, elements='str'),
)
module = AnsibleModule(
argument_spec=module_args,
supports_check_mode=True,
)
result = dict(changed=False, output={})
if module.check_mode:
module.exit_json(**result)
try:
result['output'] = parse_running_config(module.params['running_config'])
except Exception as exc:
module.fail_json(msg='Failed to parse running-config', exception=str(exc))
module.exit_json(**result)
def main():
run_module()
if __name__ == '__main__':
main()
A snippet of the config the module will see:
hostname R3
ip domain name lab.local
license udi pid ISR4431/K9 sn FGL2103A123
username admin privilege 15 secret 5 $1$abc$xyz
username guest privilege 1 secret 5 $1$def$xyz
The structured output Ansible returns to the playbook:
{
"changed": false,
"output": {
"hostname": "R3",
"domain": "lab.local",
"product_id": "ISR4431/K9",
"serial_number": "FGL2103A123",
"users": [
{"name": "admin", "privilege": 15},
{"name": "guest", "privilege": 1}
]
}
}
A few things this module gets right that the average first attempt misses. Parsing logic is in its own function, which means you can unit test parse_running_config directly without bringing up Ansible. Regular expressions use re.MULTILINE so anchors actually anchor at line boundaries. The output schema is consistent — users is always a list of dicts, never a list of strings, never None, never a single dict when there is one user. Consumers of the module never have to write defensive Jinja for missing keys.
A module that works on the first run and then keeps reporting changed: true on every subsequent run is broken, even if the playbook does the right thing. Three patterns separate a module that just runs from a module that behaves like a built-in.
Idempotency. If the module changes state, it has to check the current state first and only act when the desired state differs. A module that always writes a config file should read the file first, compare it to the desired content, and only write when they differ. Set result['changed'] = True only on real change.
def ensure_banner(module, desired_banner):
current = read_current_banner(module)
if current == desired_banner:
return False # no change
if not module.check_mode:
write_banner(module, desired_banner)
return True # changed
Check mode. A module that supports check mode tells the user what would change without actually changing anything. Pass supports_check_mode=True to AnsibleModule, and inside the module branch on module.check_mode. The pattern above does this implicitly — when module.check_mode is true, the function still reports the would-be changed state without performing the write.
Diff mode. If your module changes text-shaped state (config files, banners, ACLs), return a diff dict so users running --diff see exactly what changed:
diff = {
'before': current_config,
'after': desired_config,
}
module.exit_json(changed=True, diff=diff, msg='Banner updated')
Returning data cleanly. The output of exit_json becomes the register-ed result variable in the playbook. Keep keys stable, types consistent, and None out of the schema. Never return a Python object that does not serialize to JSON. If you want to expose data to subsequent tasks as facts, return them under the ansible_facts key:
module.exit_json(
changed=False,
ansible_facts={'parsed_config': parsed},
)
After the task runs, parsed_config is available like any other fact in the play.
A useful sanity check: anything you would write register: x and then reach for in x.something.deeply.nested is a sign the schema is wrong. Flatten the output, or break the module in two.
Test custom modules locally before you wire them into a playbook that touches real devices. There are three ways that work, in increasing fidelity to how Ansible actually runs the module.
Direct Python execution (fastest, lowest fidelity). Pull the parsing function out (as we did with parse_running_config above) and call it from a pytest test or a small driver script. This catches logic bugs without involving Ansible at all and is the right place to put the bulk of your tests.
# tests/test_command_parser.py
from library.command_parser import parse_running_config
def test_parses_hostname():
lines = ['hostname R3', 'ip domain name lab.local']
result = parse_running_config(lines)
assert result['hostname'] == 'R3'
assert result['domain'] == 'lab.local'
Ansible ad-hoc command (medium fidelity). Run the module the way Ansible runs it, with arguments parsed through AnsibleModule, but without writing a full playbook:
ansible localhost -m command_parser \
-a "running_config=['hostname R3','ip domain name lab.local']"
This catches mistakes in argument_spec, JSON return shape, and check-mode handling. It does not catch playbook-level integration issues.
Playbook with check mode (highest fidelity). Run the actual playbook with --check --diff -vvv:
ansible-playbook playbook.yml --check --diff -vvv
The -vvv shows the exact JSON arguments Ansible passed to the module and the exact JSON the module returned. If something is wrong, the answer is in this output.
For deeper debugging inside the module, use module.debug() and module.warn() instead of print():
module.debug('Parsed users: %s' % parsed['users'])
module.warn('No PID line found in config')
module.debug() output is visible at -v verbosity. module.warn() shows up regardless. Both are safe; print() is not — it pollutes the JSON Ansible expects on stdout and can break the task entirely.
For module-level testing the Ansible community uses, see ansible-test (ansible-test sanity, ansible-test units, ansible-test integration). It is the same harness used to test built-in modules, and it is the right tool once your module lives in a collection.
Almost every “it doesn’t work” question about a custom module traces back to one of five issues. Here is what to check, in order.
ERROR! couldn't resolve module/action 'command_parser' means Ansible could not find your module file. Confirm the file is in library/ next to the playbook (or in a path declared via library = in ansible.cfg), the filename matches the module name in the play exactly, the filename uses underscores not hyphens, and there is no typo in either place.
ImportError: No module named 'requests' (or any other library) means the Python environment running the module is missing a dependency. The fix depends on where the module runs. For modules that run on the control node (most network modules using network_cli, anything with delegate_to: localhost), install the library on the control node with pip. For modules that run on managed hosts, install the library on the host or use a different module. Inside Execution Environments for AAP or AWX, declare the dependency in the EE’s requirements.txt. See Ansible Execution Environment for the EE workflow end to end.
AttributeError: 'bool' object has no attribute 'get' is almost always a wrong type in argument_spec colliding with the data the playbook is actually passing. If the module declares running_config=dict(type='list') but the playbook passes a string, you get a confusing error inside your parsing code. Print module.params early in the module, or run with -vvv to see the actual argument values, and tighten the type spec.
Module Failure with no detail usually means an unhandled exception in the module body. The fix is the try/except block in the skeleton — wrap the module body, and call module.fail_json(msg=..., exception=str(exc)) so the playbook output gets a real error instead of “module failure”. A bare exception that escapes the module is the worst possible failure mode.
SyntaxError: invalid syntax in a custom module on a control node still running an older Python. Modern Ansible (ansible-core 2.14+) requires Python 3.10 or newer on the control node. If your module uses walrus operators, structural pattern matching, or modern type hints, make sure the control node Python supports them. For Python on managed network devices, see the discussion of ansible_python_interpreter in Ansible Setup Step by Step.
If none of the above match, run with -vvv. The verbose output shows the JSON Ansible sent to the module and the JSON the module sent back. Most “weird” errors are obvious once you can see the actual data on the wire.
A library/ directory is fine for one project. Once you have a few related modules, or you want to ship them through Ansible Automation Platform (AAP) or AWX, you need a collection. A collection is a versioned bundle of modules, plugins, roles, and metadata that installs through ansible-galaxy collection install and slots into Execution Environments cleanly.
The minimum collection layout looks like this:
my_namespace/
└── network_tools/
├── galaxy.yml
├── README.md
├── plugins/
│ ├── modules/
│ │ ├── command_parser.py
│ │ └── interface_status.py
│ └── module_utils/
│ └── parsers.py
├── roles/
└── meta/
└── runtime.yml
galaxy.yml is the collection manifest. It declares the namespace, name, version, dependencies, and tags:
namespace: my_namespace
name: network_tools
version: 1.0.0
readme: README.md
authors:
- Your Team <team@example.com>
description: Custom modules for parsing Cisco IOS configurations.
tags:
- networking
- cisco
- parsing
dependencies: {}
Build the collection from the project root:
ansible-galaxy collection build
You get a .tar.gz file. Install it locally to test:
ansible-galaxy collection install my_namespace-network_tools-1.0.0.tar.gz
In a playbook, reference modules by their fully qualified collection name:
- hosts: r3
tasks:
- name: Parse running-config
my_namespace.network_tools.command_parser:
running_config: ""
For AAP and AWX, the workflow is the same, with one extra step. Add the collection to your project’s requirements.yml:
collections:
- name: my_namespace.network_tools
version: ">=1.0.0"
source: https://galaxy.ansible.com # or your private Automation Hub
When AAP or AWX syncs the project (or rebuilds the Execution Environment), the collection is installed automatically. If your modules need extra Python packages, add them to the EE definition’s requirements.txt, not to the collection itself. Mixing those concerns is the most common reason a module that works locally fails in AAP. For the broader AAP picture, including how the controller, mesh, and EEs fit together, see Ansible Automation Platform Enterprise IT.
When to choose a collection over a standalone module:
Standalone module in library/ |
Collection |
|---|---|
| One project, one team | Multiple projects or teams |
| 1–2 modules | 3+ modules, or modules + filters + roles |
| No external distribution | Galaxy or private Automation Hub |
| AAP not in the picture | AAP, AWX, or custom EEs |
| Quick prototyping | Production code, versioned releases |
For everything you can do with collections that you cannot do with a flat library, see the Ansible Galaxy overview.
A custom Ansible module ages well or it does not. The modules that survive five years in production share a small set of habits.
Keep the parsing logic in plain Python functions, not inside run_module. The AnsibleModule skeleton becomes a thin shell around testable functions. You can unit test the functions with pytest and never touch Ansible.
Make the output schema boring. Every field always present, every type stable, every list a list (even when empty). Consumers of the module write Jinja, and Jinja is not the place for defensive code.
Support check mode. Every module that changes state should accept supports_check_mode=True and behave correctly under --check. This is the difference between a module operators trust and a module they wrap in block: rescue: because they are scared of it.
Fail loudly through module.fail_json, not silently through print() or unhandled exceptions. The error message is what the next engineer sees at 2 a.m.
Document the module with a DOCUMENTATION string at the top of the file. ansible-doc -t module command_parser then shows real documentation, and ansible-test sanity checks it. The Ansible developer guide on documenting modules is short and worth reading once.
Use ansible-test early. Run ansible-test sanity and ansible-test units against your collection before publishing anything. The same harness that tests ansible.builtin modules tests yours.
Pin dependencies in the EE, not in the module. Modules should declare the libraries they need, but the actual installation belongs in the Execution Environment’s requirements.txt. This makes the module portable across EEs and avoids the “works on my laptop” problem.
For the bigger automation picture — credentials, vaults, secrets — see Ansible Credentials and Ansible Vault. For tooling that makes the development loop faster, Ansible Navigator gives you a TUI on top of the same EE-based workflow that AAP uses in production.
The hardest part of writing your first custom Ansible module is picking the right problem. Pick something you already have to do every week:
show version output for OS version, uptime, and hardware serial across Cisco, Juniper, and Aristaansible.builtin.uri plus three set_fact tasksStart in library/, get one module working end to end, then graduate to a collection when you have two or three. The skeleton is the same. Only the deployment changes.
If you would rather skip the lab plumbing entirely and just get to writing modules, CloudMyLab’s hosted Ansible labs give you a managed environment with ansible-core, AAP, AWX, and Execution Environments wired up. You bring the modules; the lab is already there. Start a free trial or book a demo with one of our engineers if you want to see it before you commit.
For broader Ansible context, Ansible Setup Step by Step walks through the install on a fresh control node, Best Practices for Ansible Playbooks covers the surrounding playbook discipline, and Python Libraries for Network Automation lists the libraries most network-focused custom modules end up depending on.
Modules are the units of work Ansible runs on managed nodes (or the control node, for some tasks). They have an argument_spec, return JSON, and show up as task verbs in a playbook. Plugins extend Ansible’s behavior on the control node — filter plugins transform variables in Jinja, lookup plugins read external data into variables, action plugins wrap modules with controller-side logic, and inventory plugins build inventories. If you need a new task verb, write a module. If you need to transform data inside a template or before a task runs, write a plugin.
You do not, directly. Modules are designed to be standalone. If you need functionality similar to a built-in module, use the same Python library the built-in uses (for example, use shutil instead of trying to call ansible.builtin.copy from Python). If you need to wrap a module with controller-side logic, write an action plugin instead of trying to call modules from Python.
Create a *_info module when your job is to gather data and return it for the playbook to register and use immediately. Create a *_facts module when the data should automatically be merged into ansible_facts and made available to every subsequent task. The convention in modern Ansible is to prefer *_info modules and let users explicitly set_fact if they want fact-like behavior; *_facts is reserved mostly for legacy compatibility.
No. print() writes to stdout, which is exactly where Ansible expects the module’s JSON return value to be. Mixing the two breaks the task with a JSON parsing error. Use module.debug('message') for verbose-only output and module.warn('message') for warnings that always show. Both are safe and integrate with -v verbosity.
Modern Ansible (ansible-core 2.14 and newer) requires Python 3.10 or newer on the control node, and Python 3.6 or newer on managed nodes. New custom modules should be Python 3 only. Python 2.7 compatibility is no longer a requirement and pretending it is just adds dead code paths. If you have a managed node still running Python 2.7, the right fix is to fix the managed node, not the module.
Declare arguments in the argument_spec dictionary passed to AnsibleModule. Each key is an argument name, and the value is a dict with type, required, default, and (for lists) elements. Inside the module, read values from module.params['arg_name']. Ansible validates types, applies defaults, and enforces required-ness before your code runs. If the playbook passes an invalid argument, the task fails before run_module is called.
For two or three modules, set library = /path/to/your/modules in ansible.cfg or the ANSIBLE_LIBRARY environment variable. For anything larger, package the modules into a collection and install with ansible-galaxy collection install. Collections version cleanly, work inside Execution Environments, and are the only sensible distribution model for AAP and AWX.
Pass supports_check_mode=True to AnsibleModule. Inside the module, branch on module.check_mode before any state-changing operation. If a change would happen, return changed=True with a descriptive msg, but do not actually make the change. The pattern in the Idempotency section above is the canonical shape — read current state, compare to desired state, only write outside check mode, return whether change occurred.