Ansible Custom Modules
- Does a module already exist? Check
ansible-galaxy collection listand searchansible-doc -l | grep pattern. - If it's trivial data-shaping, write a filter plugin, not a module.
- If you just need a value at lookup time, write a lookup plugin.
- If the thing runs remote code with non-trivial state, you want a module.
- If it needs to run on the controller and touch connection internals, that's an action plugin.
- When to write a module (vs other things)
- Modules, action, filter, lookup — a table
- Minimal module skeleton
- argument_spec, required_if, mutually_exclusive
- The return contract
- check_mode handling
- diff_mode handling
- Placing modules: local, role, collection
- Worked example: ensure_json_key
- Testing with ansible-test and pytest
When to write a module (vs other things)
A module is the right answer when all of these are true:
- You need to do something on the managed host (not the controller).
- The operation has a meaningful notion of idempotent state — "present / absent" or "value set to X".
- Users will want
check_modeto report what would change. - The logic is non-trivial enough that
shell+creates:would be a lie.
It is the wrong answer when:
- You just want to transform a variable — that's a filter.
- You want to fetch a value from an external source at vars-time — that's a lookup.
- The thing runs on the controller and orchestrates modules — that's an action plugin.
- A one-liner
ansible.builtin.commandwithcreates:already does it idempotently.
Modules, action, filter, lookup — a table
| Plugin type | Runs where | Input | Output | Typical use |
|---|---|---|---|---|
| module | Managed host (shipped over SSH, executed remotely) | Module args (YAML dict) | JSON with changed, failed, msg, task-specific keys |
"Ensure X is in state Y on the target" — install, configure, CRUD |
| action plugin | Controller — wraps a module call | Task arguments plus connection/templar objects | Same as a module (it returns the wrapped module's result) | Pre-process args (expand paths, render templates locally) then call a module; or run entirely on the controller (debug, set_fact, template) |
| filter plugin | Controller — inside Jinja | A Python value (passed through the | pipe) |
A Python value | Data transforms in templates / set_fact: {{ my_list | my_filter }} |
| lookup plugin | Controller — at var-resolution time | Lookup term string(s) + kwargs | A Python list of values | {{ lookup('my_plugin', 'arg') }}; reading files, querying APIs to get data |
| callback plugin | Controller — during run | Event hooks (task start, result, play end) | Side effects (logging, notifications) | Custom reporting, sending slack on failure, profile_tasks |
| inventory plugin | Controller — at inventory build time | YAML config | Hosts and groups | Dynamic inventory from a new source (see Inventory Patterns) |
Minimal module skeleton
An Ansible module is a standalone Python script that imports AnsibleModule from ansible.module_utils.basic, reads args from stdin/argv (Ansible handles this for you), and prints a JSON result.
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright: (c) 2026, Your Name <you@example.com>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt)
from __future__ import absolute_import, division, print_function
__metaclass__ = type
DOCUMENTATION = r'''
---
module: hello
short_description: Says hello, or doesn't.
version_added: "1.0.0"
description:
- Minimal skeleton module that demonstrates the module contract.
options:
name:
description: The name to greet.
type: str
required: true
state:
description: Whether the greeting should exist.
type: str
choices: [present, absent]
default: present
author:
- Your Name (@yourhandle)
'''
EXAMPLES = r'''
- name: Greet
myorg.example.hello:
name: world
state: present
'''
RETURN = r'''
greeting:
description: The greeting that was (or would be) produced.
returned: always
type: str
sample: "hello, world"
'''
from ansible.module_utils.basic import AnsibleModule
def run_module():
module = AnsibleModule(
argument_spec=dict(
name=dict(type='str', required=True),
state=dict(type='str', choices=['present', 'absent'], default='present'),
),
supports_check_mode=True,
)
name = module.params['name']
state = module.params['state']
greeting = f"hello, {name}" if state == 'present' else ""
changed = True # real modules: determine by comparing current vs desired state
if module.check_mode:
module.exit_json(changed=changed, greeting=greeting)
module.exit_json(changed=changed, greeting=greeting, msg="done")
def main():
run_module()
if __name__ == '__main__':
main()
Save as plugins/modules/hello.py in a collection, or library/hello.py in a role.
argument_spec, required_if, mutually_exclusive
AnsibleModule's constructor validates your inputs before your code runs. Use it aggressively — every validation rule you put here is one you don't have to write.
module = AnsibleModule(
argument_spec=dict(
path=dict(type='path', required=True),
key=dict(type='str', required=True),
value=dict(type='raw'), # accept anything serialisable
value_file=dict(type='path'),
state=dict(type='str', choices=['present', 'absent'], default='present'),
create=dict(type='bool', default=False),
mode=dict(type='str'), # file mode; octal string
owner=dict(type='str'),
group=dict(type='str'),
),
required_if=[
('state', 'present', ('value', 'value_file'), True),
],
mutually_exclusive=[
('value', 'value_file'),
],
required_together=[],
required_one_of=[],
supports_check_mode=True,
)
The supported types
| Type | Meaning |
|---|---|
str | String |
int / float | Numbers; coerced from strings |
bool | Accepts true/false/yes/no/1/0 |
list | Python list; set elements= to validate each element |
dict | Arbitrary mapping |
path | Filesystem path; ~ and env vars expanded |
raw | No coercion; anything the user passed |
json | String that must parse as JSON |
bytes | Byte-suffixed size (1M, 2Gi) → int bytes |
jsonarg | Dict that will be JSON-serialised for the module |
Cross-field validators
required_if=[(field, value, (other_fields,), all_or_any)]— other_fields are required when field=value.mutually_exclusive=[('a','b')]— at most one of a/b.required_together=[('a','b')]— if one is given, both must be.required_one_of=[('a','b','c')]— at least one must be given.required_by={'field': ('other_required_field',)}— field being set requires others.
The return contract
Modules communicate with Ansible by printing a single JSON object to stdout. AnsibleModule does this for you via exit_json and fail_json. The keys Ansible recognises:
| Key | Meaning |
|---|---|
changed | Bool. Did the module modify state? (Even if check_mode, set true when it would.) |
failed | Bool. Set by fail_json; do not set manually. |
msg | Human-readable summary. Always include on failure. |
diff | Dict with before/after/before_header/after_header. Populate when module._diff. |
rc | Integer return code for command-like modules. |
stdout, stderr | Strings; useful for command-like modules. |
invocation | Auto-populated by AnsibleModule; don't touch. |
| Your keys | Document them in the RETURN block. |
module.exit_json(changed=True, msg="key set", before=old_value, after=new_value)
module.fail_json(msg=f"could not parse {path}: {err}", path=path)
print() in your module breaks Ansible's JSON parser and produces MODULE FAILURE: module did not respond with valid JSON. Log to stderr, or use module.log() / module.debug().
check_mode handling
Set supports_check_mode=True in the constructor, then branch on module.check_mode before doing any write. If check_mode is on, compute would it change and return — no writes.
current = read_current_state(path)
desired = compute_desired(current, module.params)
if current == desired:
module.exit_json(changed=False, msg="already in desired state")
if module.check_mode:
module.exit_json(changed=True, msg="would change", before=current, after=desired)
write_state(path, desired)
module.exit_json(changed=True, msg="changed", before=current, after=desired)
Think of it as dry-run semantics: read-only is allowed; writes are not. A module that pretends to support check-mode but silently writes is a liability — reviewers will stop trusting --check --diff.
diff_mode handling
Check module._diff (note the underscore). If set, include a diff key in your result so Ansible can render it under --diff.
result = dict(changed=changed, msg=msg)
if module._diff:
result['diff'] = dict(
before_header=path,
after_header=path,
before=render(current), # string, with trailing newlines
after=render(desired),
)
module.exit_json(**result)
For text diffs, pass the full rendered strings (file contents, pretty-JSON); Ansible produces the unified diff itself. For structured diffs, pass dicts and Ansible will print them.
Placing modules: local, role, collection
| Location | Good for | How to use |
|---|---|---|
./library/my_module.py at playbook root |
One-off in a single repo | Just use my_module: in a task — Ansible finds it |
roles/x/library/my_module.py |
Module that belongs with a specific role | Available to that role; if the role is included in a play, the module is visible |
plugins/modules/my_module.py in a collection |
Anything you want to reuse across projects | Use FQCN: myorg.mycoll.my_module. Publish to Galaxy or load from a git URL in requirements.yml |
~/.ansible/plugins/modules/ |
Personal scratch | Picked up automatically; don't commit plays that rely on it |
The canonical grown-up answer is a collection. See Ansible Collection for the wider structure.
Collection layout for a module
ansible_collections/myorg/mycoll/
├── galaxy.yml
├── plugins/
│ ├── modules/
│ │ └── ensure_json_key.py
│ ├── module_utils/
│ │ └── jsonhelpers.py # shared code
│ ├── action/ # optional action plugins
│ ├── filter/ # optional filter plugins
│ └── lookup/ # optional lookup plugins
├── roles/
├── tests/
│ ├── sanity/
│ │ └── ignore-2.17.txt
│ ├── unit/
│ │ └── plugins/modules/test_ensure_json_key.py
│ └── integration/
│ └── targets/ensure_json_key/
│ └── tasks/main.yml
└── README.md
Worked example: ensure_json_key
A small but real module. It loads a JSON file, ensures a given key (possibly a dotted path) is set to a given value, writes only if changed, supports check-mode and diff, and returns both before/after.
#!/usr/bin/python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, print_function
__metaclass__ = type
DOCUMENTATION = r'''
---
module: ensure_json_key
short_description: Ensure a key in a JSON file is set to a value.
version_added: "1.0.0"
description:
- Loads a JSON file, walks a dotted path, and ensures that key exists with the given value.
- Supports check_mode and diff.
options:
path:
description: Path to the JSON file.
type: path
required: true
key:
description: Dotted key path, e.g. "app.logging.level".
type: str
required: true
value:
description: Desired value. Any JSON-serialisable type.
type: raw
required: true
create:
description: Create the file (with {}) if missing.
type: bool
default: false
pretty:
description: Pretty-print with 2-space indent when writing.
type: bool
default: true
author:
- Your Name (@yourhandle)
'''
EXAMPLES = r'''
- name: Ensure log level is DEBUG
myorg.example.ensure_json_key:
path: /etc/myapp/config.json
key: app.logging.level
value: DEBUG
create: true
'''
RETURN = r'''
path:
description: The path that was read/written.
returned: always
type: str
before:
description: The value at the key before (null if absent).
returned: always
type: raw
after:
description: The value at the key after.
returned: always
type: raw
'''
import json
import os
from ansible.module_utils.basic import AnsibleModule
_MISSING = object()
def walk_get(doc, path):
cur = doc
for part in path:
if not isinstance(cur, dict) or part not in cur:
return _MISSING
cur = cur[part]
return cur
def walk_set(doc, path, value):
cur = doc
for part in path[:-1]:
if part not in cur or not isinstance(cur[part], dict):
cur[part] = {}
cur = cur[part]
cur[path[-1]] = value
def run_module():
module = AnsibleModule(
argument_spec=dict(
path=dict(type='path', required=True),
key=dict(type='str', required=True),
value=dict(type='raw', required=True),
create=dict(type='bool', default=False),
pretty=dict(type='bool', default=True),
),
supports_check_mode=True,
)
path = module.params['path']
key_path = [p for p in module.params['key'].split('.') if p]
new_value = module.params['value']
create = module.params['create']
pretty = module.params['pretty']
if not os.path.exists(path):
if not create:
module.fail_json(msg=f"{path} does not exist and create=false")
doc = {}
else:
try:
with open(path, 'r', encoding='utf-8') as fh:
raw = fh.read()
doc = json.loads(raw) if raw.strip() else {}
except (OSError, ValueError) as err:
module.fail_json(msg=f"could not read {path}: {err}")
current = walk_get(doc, key_path)
before = None if current is _MISSING else current
if current is not _MISSING and current == new_value:
module.exit_json(changed=False, path=path, before=before, after=before)
walk_set(doc, key_path, new_value)
result = dict(
changed=True,
path=path,
before=before,
after=new_value,
)
rendered = json.dumps(doc, indent=2 if pretty else None, sort_keys=True) + "\n"
if module._diff:
result['diff'] = dict(
before_header=path,
after_header=path,
before="" if before is _MISSING else (json.dumps(_only_key(key_path, current), indent=2) + "\n"),
after=json.dumps(_only_key(key_path, new_value), indent=2) + "\n",
)
if module.check_mode:
module.exit_json(**result)
try:
tmp = path + ".tmp"
with open(tmp, 'w', encoding='utf-8') as fh:
fh.write(rendered)
os.replace(tmp, path)
except OSError as err:
module.fail_json(msg=f"could not write {path}: {err}")
module.exit_json(**result)
def _only_key(path, value):
out = {}
cur = out
for part in path[:-1]:
cur[part] = {}
cur = cur[part]
cur[path[-1]] = value
return out
def main():
run_module()
if __name__ == '__main__':
main()
Using it in a play
- name: Set log level to INFO
myorg.example.ensure_json_key:
path: /etc/myapp/config.json
key: app.logging.level
value: INFO
create: true
Running twice: second run reports changed=false. Running with --check --diff: prints the would-be diff without writing.
Testing with ansible-test and pytest
Two layers: sanity checks the module's shape (docs, imports, style); unit tests the Python logic in isolation; integration runs the module against a real container.
Sanity
cd ansible_collections/myorg/mycoll
ansible-test sanity --docker default plugins/modules/ensure_json_key.py -v
Common failures: missing DOCUMENTATION/RETURN blocks, f-strings in a module that declares older Python compat, undocumented arguments.
Unit tests
Ansible ships helpers that let you instantiate AnsibleModule with mocked stdin:
# tests/unit/plugins/modules/test_ensure_json_key.py
import json
import os
import pytest
from unittest.mock import patch
from ansible_collections.myorg.mycoll.plugins.modules import ensure_json_key
def _set_module_args(args):
args = json.dumps({"ANSIBLE_MODULE_ARGS": args})
import sys
sys.stdin = type("F", (), {"read": lambda self=None: args})()
def test_sets_missing_key(tmp_path):
cfg = tmp_path / "c.json"
cfg.write_text('{}')
_set_module_args({
"path": str(cfg),
"key": "a.b.c",
"value": 42,
"_ansible_check_mode": False,
})
with pytest.raises(SystemExit):
ensure_json_key.main()
assert json.loads(cfg.read_text()) == {"a": {"b": {"c": 42}}}
def test_noop_when_value_matches(tmp_path):
cfg = tmp_path / "c.json"
cfg.write_text(json.dumps({"a": {"b": {"c": 42}}}))
_set_module_args({
"path": str(cfg),
"key": "a.b.c",
"value": 42,
"_ansible_check_mode": False,
})
before = cfg.read_text()
with pytest.raises(SystemExit):
ensure_json_key.main()
assert cfg.read_text() == before
Run:
ansible-test units --docker default plugins/modules/ensure_json_key.py -v
Integration tests
Integration tests are Ansible tasks that exercise the module in a container. Ansible's harness discovers them under tests/integration/targets/<module>/.
# tests/integration/targets/ensure_json_key/tasks/main.yml
---
- name: Prepare
ansible.builtin.copy:
dest: /tmp/cfg.json
content: "{}\n"
mode: '0644'
- name: Set a key
myorg.mycoll.ensure_json_key:
path: /tmp/cfg.json
key: app.logging.level
value: INFO
register: first
- ansible.builtin.assert:
that:
- first.changed
- first.after == "INFO"
- name: Set the same key again (idempotent)
myorg.mycoll.ensure_json_key:
path: /tmp/cfg.json
key: app.logging.level
value: INFO
register: second
- ansible.builtin.assert:
that:
- not second.changed
- name: Check-mode change reports changed=true but does not write
myorg.mycoll.ensure_json_key:
path: /tmp/cfg.json
key: app.logging.level
value: DEBUG
check_mode: true
register: dry
- ansible.builtin.assert:
that:
- dry.changed
- dry.after == "DEBUG"
- name: Confirm file on disk still has old value
ansible.builtin.slurp:
src: /tmp/cfg.json
register: disk
- ansible.builtin.assert:
that:
- (disk.content | b64decode | from_json).app.logging.level == "INFO"
Run with:
ansible-test integration --docker default ensure_json_key -v
Related reading: Ansible Collection, Ansible Testing, Best Practices, Error Handling, Jinja2.