Write a Custom Ansible Module
Write a custom Ansible module in this hands-on Python for DevOps tutorial. Learn the core concepts, step-by-step implementation, and troubleshooting tips to extend Ansible with your own automation.
Focus: write a custom ansible module
You've mastered playbooks, roles, and collections, but sooner or later you'll hit a wall: Ansible's library doesn't have the module you need. Maybe it's a vendor API, a proprietary config format, or an internal service that needs a specific idempotent action. That's when you need to write a custom Ansible module — and the best part is that you already know the language for it: Python. In this lesson, you'll learn the core concepts, build a fully functional module from scratch, and understand how to test, troubleshoot, and integrate it into your playbooks.
The problem this lesson solves
Ansible ships with hundreds of modules, but your infrastructure is unique. The moment you need to automate something that isn't covered by command, uri, or template, you're stuck. Workarounds like shell with curl and jq are brittle, non-idempotent, and painful to debug. You end up with playbooks that only work on your machine, or worse, that make changes every time they run.
That's the pain point: you need a reusable, idempotent, testable building block that behaves like a first-class Ansible module. The standard solution is to write one in Python, using Ansible's module framework. It's not as hard as it sounds — a basic module is a Python script that reads JSON from stdin, does something, and prints JSON to stdout. Once you see that pattern, you'll never fear writing another module again.
Core concept / mental model
Think of an Ansibles module as a microservice inside a playbook. It's a self-contained program that:
- Receives parameters as JSON on stdin
- Performs a focused task (query an API, manage a file, restart a service)
- Returns structured results as JSON on stdout
Ansible runs your module on the target host (or the controller if you use connection: local), captures the output, and makes the results available to the playbook. The key to Ansible's magical idempotency is the changed flag: your module tells Ansible whether it actually modified something, and Ansible uses that to report "changed" or "ok".
A mental model: your module is a state machine. It compares the current state (from the system or API) with the desired state (passed as parameters). If they match, it does nothing and returns changed: false. If they don't, it applies the change and returns changed: true. That's the heart of every good module.
How it works step by step
Writing a module is a structured process. Here's the high-level flow you'll follow:
- Set up the module skeleton — import
AnsibleModulefromansible.module_utils.basic. - Define the argument spec — which parameters your module accepts, their types, required/optional, and defaults.
- Implement the core logic — fetch current state, compare with desired state, apply changes.
- Return results — use
exit_json()for success andfail_json()for errors. - Make it executable — add a shebang and set the executable bit.
- Test locally — run the module directly with sample JSON input before putting it in a playbook.
- Place it in a library path — Ansible finds modules in
library/next to your playbook, in roles, or in collections.
Let's dive into each part with a real example.
Hands-on walkthrough
We'll build a module that manages a simple maintenance mode flag on a service via a REST API. It's practical, demonstrates HTTP calls, and shows idempotency. But first, let's see the absolute minimum module so you understand the skeleton.
Minimal module: check a file's existence
#!/usr/bin/python
from ansible.module_utils.basic import AnsibleModule
def main():
module = AnsibleModule(
argument_spec=dict(
path=dict(type='str', required=True)
)
)
path = module.params['path']
exists = os.path.exists(path)
module.exit_json(changed=False, exists=exists)
if __name__ == '__main__':
main()
Run it locally with:
chmod +x check_file.py
python check_file.py '{"path": "/tmp"}'
Expected output:
{"changed": false, "exists": true, "invocation": {"module_args": {"path": "/tmp"}}}
That's it! You've written a module. Everything else is just your business logic.
Practical module: toggle maintenance mode via API
Now let's build something more useful. We'll call a hypothetical API endpoint that returns whether maintenance mode is on, and we'll set it if needed.
#!/usr/bin/python
import json
import urllib.request
import urllib.error
from ansible.module_utils.basic import AnsibleModule
BASE_URL = 'http://localhost:8080'
def get_maintenance_mode(module):
try:
req = urllib.request.Request(f'{BASE_URL}/maintenance')
with urllib.request.urlopen(req, timeout=5) as resp:
return json.loads(resp.read())['enabled']
except urllib.error.HTTPError as e:
module.fail_json(msg=f"Failed to query maintenance mode: {e}", status=e.code)
def set_maintenance_mode(module, enabled):
try:
data = json.dumps({'enabled': enabled}).encode()
req = urllib.request.Request(f'{BASE_URL}/maintenance', data=data, headers={'Content-Type': 'application/json'}, method='PUT')
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.status
except urllib.error.HTTPError as e:
module.fail_json(msg=f"Failed to set maintenance mode: {e}", status=e.code)
def main():
module = AnsibleModule(
argument_spec=dict(
state=dict(type='str', choices=['on', 'off'], required=True),
url=dict(type='str', default=BASE_URL)
),
supports_check_mode=True
)
state = module.params['state']
desired = state == 'on'
current = get_maintenance_mode(module)
if current == desired:
module.exit_json(changed=False, state=state, current=current)
if module.check_mode:
module.exit_json(changed=True, state=state, current=current, msg="Would change maintenance mode")
set_maintenance_mode(module, desired)
module.exit_json(changed=True, state=state, current=desired)
if __name__ == '__main__':
main()
Test it with:
python maintenance_mode.py '{"state": "on", "url": "http://localhost:8080"}'
Expected output:
{"changed": true, "current": true, "invocation": {"module_args": {"state": "on", "url": "http://localhost:8080"}}, "state": "on"}
Place it in a library/ folder next to your playbook and use it:
---
- hosts: localhost
connection: local
tasks:
- name: Enable maintenance mode
maintenance_mode:
state: on
url: "http://localhost:8080"
Run with ansible-playbook playbook.yml and you'll see the module work like any built-in.
Compare options / when to choose what
When you need custom automation, you have several paths. Here's a quick comparison:
| Approach | Idempotent? | Reusable? | Complexity | When to choose |
|---|---|---|---|---|
command/shell + curl |
No | Low | Low | Quick one-off tasks, testing |
uri module |
Partial | Medium | Medium | Simple API calls with manual when conditions |
| Custom module | Yes | High | Medium-High | Reusable logic, complex workflows, or when you need true idempotency |
| Action plugin + module | Yes | High | High | When you need controller-side processing, interfaces, or more complex execution |
Pro tip: If your logic is needed in one playbook,
urimight suffice. If you find yourself copying the same shell commands across multiple playbooks, invest the 20 minutes to write a module.
Troubleshooting & edge cases
- Module not found — Make sure your module file is in
library/directory relative to the playbook, or specifyANSIBLE_LIBRARYenvironment variable. Ansible looks forlibrary/in the playbook directory, role'slibrary/, and the configured modules path. - Permission denied — Ensure the module file has the executable bit (
chmod +x). Ansible runs it as a script; if not executable, it fails with a cryptic error. - Python dependencies — If your module uses non-standard libraries (like
requests), they must be available on the target host. Preferurllibfrom the standard library to avoid dependency headaches. - Check mode not working — If you don't explicitly handle
check_modein your module, Ansible will still run it as normal. Usesupports_check_mode=Trueand add early returns withchanged=True. Otherwise users will be surprised when--checkactually modifies things. - Unhandled exceptions — Always catch exceptions and call
fail_json()with a descriptive message. An unhandled traceback will be shown, which is ugly and less useful for playbook users. - Idempotency leaks — When your module talks to an API, make sure you compare the desired state against the current state before making changes. For example, if you're adding a user, first check if it already exists.
- Module output buffer — Don't use
print()in your module. Onlyexit_json()andfail_json()should write to stdout. Debug withmodule.log()or write to stderr.
What you learned & what's next
You've learned the core of writing a custom Ansible module:
- How Ansible modules work under the hood (JSON in, JSON out)
- The anatomy of a module:
AnsibleModule, argument spec, exit/fail JSON - How to make your module idempotent with the
changedflag - How to implement check mode for dry-run safety
- Where to place modules and how to call them from playbooks
You've also completed a hands-on exercise: a module that toggles maintenance mode via a REST API. This pattern — query, compare, update, report — applies to countless other tasks, from managing cloud resources to configuring services.
The next step in your Python for DevOps journey is to explore Ansible action plugins and filters — they let you run code on the controller side and manipulate data within playbooks. Or you could dive into Ansible collections to package and distribute your custom modules to your team. Whatever you choose, you're now equipped to extend Ansible far beyond its built-in capabilities.
Keep your module code clean, testable, and idempotent, and you'll be the hero of your automation team.
Practice recap
Great work! Now take the maintenance-mode module you built and extend it: add a timeout parameter, validate that it's an integer, and make the module fail gracefully if the API times out. Also, write a small playbook that uses this module in check mode first, then in normal mode, and observe how changed behaves. This will solidify your understanding of idempotency and error handling in custom modules.
Common mistakes
- Forgetting to set the executable bit (
chmod +x) on the module file, causing Ansible to fail with a permission error. - Using
print()instead ofexit_json()to return results, leading to malformed JSON output and cryptic failures. - Not handling
check_modeproperly — your module runs and makes changes even during--checkunless you explicitly support it. - Assuming Python dependencies like
requestsare available on the target host; stick tourllibor document the requirements clearly. - Returning
changed: trueeven when nothing changed, breaking idempotency expectations and confusing playbook output.
Variations
- Use a library directory in your role to keep modules organized with the roles that use them.
- Instead of a standalone module, build a collection (e.g.,
mycollection.mymodule) to package and distribute your modules and dependencies. - For tasks that require controller-side processing (like reading local files or generating dynamic arguments), write an action plugin that wraps your module.
Real-world use cases
- Automate enabling or disabling maintenance mode on a fleet of microservices behind a REST API, ensuring the playbook is idempotent across re-runs.
- Build a module that manages users in a proprietary SaaS platform (e.g., create/update/delete via HTTP), eliminating manual console clicks and shell hacks.
- Create a module that ensures a database table schema or configuration file is in the desired state, checking current settings before applying changes.
Key takeaways
- An Ansible module is a Python script that reads JSON from stdin and writes JSON to stdout — nothing magical.
- The
changedflag is the key to idempotency: always compare current state with desired state before making changes. - Use
exit_json()andfail_json()exclusively for output — neverprint(). - Always support
check_modeby addingsupports_check_mode=Trueand early returns for dry-run safety. - Place your module in a
library/directory next to your playbooks or in a role'slibrary/for automatic discovery. - Prefer Python's standard library (like
urllib) to avoid extra dependencies on target hosts.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.