Ansible Custom Modules

When to write a module, how to structure one, how to handle check_mode and diff properly, and where modules fit among action/filter/lookup plugins. With a working example.

Before you write a module
  • Does a module already exist? Check ansible-galaxy collection list and search ansible-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)

A module is the right answer when all of these are true:

It is the wrong answer when:

Modules, action, filter, lookup — a table

Plugin typeRuns whereInputOutputTypical 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

TypeMeaning
strString
int / floatNumbers; coerced from strings
boolAccepts true/false/yes/no/1/0
listPython list; set elements= to validate each element
dictArbitrary mapping
pathFilesystem path; ~ and env vars expanded
rawNo coercion; anything the user passed
jsonString that must parse as JSON
bytesByte-suffixed size (1M, 2Gi) → int bytes
jsonargDict that will be JSON-serialised for the module

Cross-field validators

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:

KeyMeaning
changedBool. Did the module modify state? (Even if check_mode, set true when it would.)
failedBool. Set by fail_json; do not set manually.
msgHuman-readable summary. Always include on failure.
diffDict with before/after/before_header/after_header. Populate when module._diff.
rcInteger return code for command-like modules.
stdout, stderrStrings; useful for command-like modules.
invocationAuto-populated by AnsibleModule; don't touch.
Your keysDocument 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)
Don't print to stdout. A stray 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

LocationGood forHow 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.