Ansible Lesson 18 of 42

Writing Custom Ansible Modules, In Depth: AnsibleModule, argument_spec, Idempotency & check_mode

Ansible ships with thousands of modules — ansible.builtin.copy, ansible.builtin.service, community.general.ufw, amazon.aws.ec2_instance — and for the overwhelming majority of automation you will never need to write your own. But eventually you hit the wall: an internal REST API nobody has wrapped, a bespoke appliance with its own CLI, a licence server that speaks a proprietary protocol, a domain-specific resource your platform team owns. You could drive it with ansible.builtin.command or ansible.builtin.uri and a pile of register, changed_when, and failed_when glue — and that is the right first move for a one-off. But the moment that glue is copy-pasted into three playbooks, lies about changed, breaks under --check, and produces unreadable output, you have outgrown the shell-out. The clean answer is a custom module: a small Python program that takes typed parameters, talks to the thing, reports back in Ansible’s structured JSON contract, is idempotent, and honours check mode — a first-class citizen that looks and behaves exactly like the built-ins.

A module is a peculiar kind of program. You do not run it yourself; Ansible runs it on the managed node (or, for connection: local and many cloud modules, on the control node acting as the target). Ansible serialises your task’s parameters to JSON, ships your module’s code to the target, executes it with the Python interpreter found there, and reads a single line of JSON from its stdout. That JSON — changed, an optional failed, plus whatever facts and results you choose to return — is the module’s entire interface to the rest of Ansible. Get the contract right and your module composes with register, when, --check, --diff, loops, and handlers for free. Get it wrong and nothing downstream behaves. This lesson is about getting it exactly right.

This is the exhaustive version. By the end you will know when a module is the correct tool versus a role, a command/shell call, or a script; the full anatomy of a module file; AnsibleModule and every key of argument_spectype, required, default, choices, no_log, aliases, elements, options (sub-specs), fallback, deprecated_aliases, and the apply_defaults behaviour — plus the cross-argument constraints mutually_exclusive, required_together, required_one_of, required_if, and required_by, and supports_check_mode; the return contract (module.exit_json(changed=…, **result), module.fail_json(msg=…), the conventional keys, the diff dict, returning ansible_facts, module.warn()/module.deprecate()); how to build genuine idempotency (read current state → compare to desired → change only on drift → report the truthful changed) and how to honour module.check_mode; the three placement options (library/, a role’s library/, a collection’s plugins/modules/) and the search precedence; the three documentation blocks — DOCUMENTATION, EXAMPLES, RETURN — that feed ansible-doc; module_utils for shared code and how to import it correctly; how to debug a module locally; and how to test it (sanity + unit). Everything uses FQCN and reflects current ansible-core 2.17+ / Ansible 10+ (2026). We finish with a complete, working worked module — a line-in-a-JSON-config manager — that you can drop into library/ and run for free against localhost.

Learning objectives

After working through this lesson you will be able to:

Prerequisites & where this fits

You should be fluent with everything up to the Developing tier of this course: writing playbooks, using register to capture results, the changed_when/failed_when overrides (which exist precisely because command/shell cannot judge change for themselves), roles (your module may live inside one), and collections (the production home for a module — see the roles & collections lesson). You also need working Python 3 — modules are Python programs, though the Python here is deliberately plain (standard library, a try/except, a dictionary or two). You do not need to be a Python expert; the AnsibleModule helper does the heavy lifting of argument parsing, type-checking, and JSON I/O. In the Ansible Zero-to-Hero ladder this is the first Developing lesson — the step from using Ansible’s building blocks to building new ones. It follows the debugging lesson (you will lean on --check, --diff, and -vvv to develop a module) and leads directly into Ansible plugins (filter/lookup/callback/connection — code that, unlike modules, runs on the control node). The distinction between a module (runs on the target, does a unit of work, reports changed) and a plugin (runs on the control node, extends Ansible itself) is the spine of the whole Developing tier, so fix it now. Everything in this lesson is free — the lab runs against localhost.

Core concepts

Six ideas carry the lesson; internalise them before the code.

A module is a program Ansible runs on the target, not on the control node. When a task invokes your module, Ansible takes the module’s Python source plus a small bundle of module_utils, packages it (the “AnsiBallZ” wrapper), copies it to the managed node’s temp directory, runs it with the target’s Python interpreter (the one resolved by ansible_python_interpreter), then deletes it. Your module reads its parameters from a file Ansible wrote next to it and prints exactly one JSON object to stdout. This is the opposite of a plugin (filter, lookup, callback, connection), which runs inside the Ansible process on the control node. If your code needs the target’s filesystem, packages, services, or local CLI — it is a module. If it transforms data for the playbook or hooks Ansible’s own behaviour — it is a plugin.

The return JSON is the entire interface. Ansible does not inspect your module’s exit code in any nuanced way, nor parse free text. It reads one JSON document. The keys it understands are changed (did this task alter the system?), failed (did it error?), msg (human-readable message), ansible_facts (variables to inject into the host), diff (before/after for --diff), warnings, deprecations, and invocation (echo of the parameters, added for you). Every other key you return becomes available under the task’s registered variable. Get this contract right and register, when, loops, handlers (changed drives notify), --check, and --diff all work automatically.

changed must be the truth. The single most important value your module returns is changed. It must be true only if the module actually altered the managed system on this run, and false if the system was already in the desired state. This is what makes Ansible declarative: a second run of the same play reports changed=0 because nothing needed doing. A module that always returns changed=true is a bug — it breaks the idempotence test, fires handlers needlessly, and lies in reports. The discipline is: read the current state, compare it with the requested state, and set changed based on whether they differed.

Check mode is a contract, not a freebie. When the user runs --check (dry run), Ansible sets module.check_mode = True. Your module must then predict what it would do — compute the would-be changedwithout making any change. You opt in by declaring supports_check_mode=True; if you do not, Ansible skips your module entirely under --check (it shows skipping rather than risk an unsafe write). Supporting check mode well is a hallmark of a production-grade module and is explicitly graded on EX374.

Idempotency lives in your code. Built-in modules feel idempotent because their authors wrote the read-compare-change logic. Nothing about AnsibleModule makes your module idempotent automatically — if you just “do the thing” every time and return changed=true, you have written a glorified command. Idempotency is the differentiator between a real module and a shell-out, and implementing it is most of the work.

A module is documented in-band. A proper module carries three YAML strings in the file itself — DOCUMENTATION, EXAMPLES, RETURN — which ansible-doc <module> renders. This is not optional polish: the sanity tests enforce it, ansible-doc and IDE plugins read it, and it is part of the collection contract. You write the docs in the module, beside the code they describe.

Keep these terms straight, because the rest of the lesson uses them precisely.

Term Meaning
Module A program Ansible ships to and runs on the target; does a unit of work; returns one JSON object with changed.
Plugin Code that runs on the control node, extending Ansible itself (filter, lookup, callback, connection, …) — the next lesson.
AnsibleModule The helper class (from ansible.module_utils.basic import AnsibleModule) that parses params, type-checks, handles JSON I/O, and provides exit_json/fail_json/check_mode.
argument_spec The dict declaring each parameter your module accepts — its type, default, choices, and validation rules.
The contract The set of JSON keys Ansible understands (changed, failed, msg, ansible_facts, diff, …).
check_mode Dry-run flag (module.check_mode); when True, predict changes without making them.
Idempotency The property that running the module twice changes the system at most once; you implement it.
AnsiBallZ Ansible’s wrapper that bundles your module + module_utils into one executable payload for the target.

When to write a module (versus role, command, script, or uri)

Writing a module is real engineering — Python, documentation, tests, a release in a collection. Reach for it deliberately. This decision table is the first thing an EX374 examiner (and a sensible architect) wants you to get right.

Need Best tool Why
Compose existing modules into a reusable, parameterised unit of automation A role A role orchestrates tasks; it does not do new low-level work. Most “I need something custom” is actually a role.
Run a one-off external command, idempotency handled by creates/changed_when ansible.builtin.command (or shell if you need pipes/redirection) Fast, no code. Pair with creates:/removes: and changed_when: to fake idempotency for simple cases.
Copy and execute a local script on the target ansible.builtin.script Ships and runs a script; good for legacy installers. No structured output or real idempotency, though.
Talk to a generic HTTP/REST API for a few calls ansible.builtin.uri Handles HTTP verbs, auth, JSON bodies, status assertions — often enough without writing code.
Manage a resource (API, appliance, CLI) idempotently, with typed inputs, used repeatedly, with clean --check/--diff and readable output A custom module The only option that gives true idempotency, type validation, check-mode prediction, structured returns, and ansible-doc.
Transform data, generate values, or hook Ansible’s behaviour (output formatting, inventory, connection) A plugin (next lesson) Runs on the control node; modules cannot do this.

The litmus test: “Am I managing the state of a resource on a target, repeatedly, where re-running should be a no-op?” If yes — and command/uri glue is getting ugly or unreliable — write a module. If you are merely arranging existing modules, write a role. If you are transforming data for the playbook, write a plugin.

Module anatomy: the file from top to bottom

A module is a single .py file with a fixed, conventional shape. Here is the skeleton every module follows; we will fill each region in the sections that follow.

#!/usr/bin/python
# -*- coding: utf-8 -*-

# Copyright: (c) 2026, Vinod H <h.vinod@example.com>
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)

from __future__ import absolute_import, division, print_function
__metaclass__ = type

DOCUMENTATION = r'''
---
module: my_module
short_description: One-line summary
# ... full YAML, see the documentation-blocks section
'''

EXAMPLES = r'''
- name: Use the module
  namespace.collection.my_module:
    name: example
    state: present
'''

RETURN = r'''
changed:
  description: Whether the module made a change.
  type: bool
  returned: always
'''

from ansible.module_utils.basic import AnsibleModule
# (other imports: standard library, then module_utils)


def run_module():
    # 1. Declare the interface
    argument_spec = dict(
        name=dict(type='str', required=True),
        state=dict(type='str', default='present', choices=['present', 'absent']),
    )

    # 2. Instantiate the helper
    module = AnsibleModule(
        argument_spec=argument_spec,
        supports_check_mode=True,
    )

    # 3. Pull validated params
    name = module.params['name']
    state = module.params['state']

    # 4. Build the result skeleton
    result = dict(changed=False)

    # 5. Read current state, compare, act idempotently (honouring check_mode)
    #    ... the real logic ...

    # 6. Return success
    module.exit_json(**result)


def main():
    run_module()


if __name__ == '__main__':
    main()

Several conventions here are load-bearing, not decoration:

Element Why it matters
#!/usr/bin/python shebang Convention; the actual interpreter used on the target is ansible_python_interpreter. Keep the line — sanity tests expect it.
from __future__ import … + __metaclass__ = type Required boilerplate the sanity tests enforce (Python 2/3 hygiene legacy; still mandated for collection modules).
DOCUMENTATION/EXAMPLES/RETURN as module-level strings Must be top-level assignments (not inside functions) so ansible-doc can extract them by parsing the file without importing it. Use raw strings (r'''…''') so backslashes in regexes survive.
Imports after the doc strings So the doc extractor sees the docs first; also avoids import side-effects before docs are read. Import AnsibleModule first, then stdlib, then your module_utils.
run_module() / main() split Convention that makes the module importable for unit tests without executing — tests call run_module with mocked args; the if __name__ == '__main__' guard runs it only when executed by Ansible.
module.exit_json(**result) at the end The only correct way to finish successfully — it prints the JSON and sys.exit(0)s. Never print() or return your result.

AnsibleModule and the complete argument_spec

AnsibleModule is the heart of every module. You import it, hand it your argument_spec (and the cross-argument rules), and it: parses the JSON parameters Ansible passed, type-checks and coerces each one, applies defaults, enforces required/choices/mutual-exclusion rules (failing cleanly with a good msg if violated), strips no_log values from logs, and exposes the clean values on module.params. It also gives you module.check_mode, module.exit_json(), module.fail_json(), module.warn(), module.run_command(), and a pile of filesystem helpers.

module = AnsibleModule(
    argument_spec=argument_spec,
    supports_check_mode=True,
    mutually_exclusive=[('content', 'src')],
    required_one_of=[('content', 'src')],
    required_if=[('state', 'present', ('name',))],
    required_together=[('username', 'password')],
    required_by={'auth_token': ('endpoint',)},
)

Per-argument keys

Each entry in argument_spec is a dict describing one parameter. This is the complete reference table — memorise it; every key here appears on the EX374 syllabus.

Key What it does Values / example Default Gotcha
type Declares and coerces the parameter’s type 'str', 'int', 'float', 'bool', 'list', 'dict', 'path', 'raw', 'json', 'bytes', 'bits' 'str' if omitted Always set it. 'path' expands ~ and env vars; 'bool' accepts yes/no/true/false/1/0; 'list' accepts a CSV string or a list.
required Caller must supply it True / False False Mutually exclusive with default — a required param has no default.
default Value used when caller omits it any matching the type None Use it to express the common case; document the default in DOCUMENTATION.
choices Restrict to an enumerated set ['present', 'absent', 'latest'] none AnsibleModule rejects anything else with a clear error. Ideal for state.
no_log Scrub the value from output, -v logs, and invocation True / False False (but auto-True if the name looks secret, e.g. password) Set no_log=True on every secret. Conversely set no_log=False to silence the false-positive warning on a non-secret named like *_token that is actually safe.
aliases Alternative parameter names ['pkg', 'package'] none Lets you rename a param without breaking callers; module.params uses the canonical name.
elements Type of each item in a type='list' 'str', 'int', 'dict', … none Required for validated lists. With elements='dict', add options= to validate each dict’s sub-keys.
options A nested argument_spec for type='dict' (or list of dicts) a dict of sub-arguments none Enables full validation of structured input (suboptions). The cross-arg rules can be applied per-suboption too.
apply_defaults For a type='dict' with options: apply sub-defaults even when the dict itself is omitted True / False False Use when you want suboption defaults to materialise without the caller passing the parent.
fallback Pull a value from elsewhere if the caller omits it (env_fallback, ['MY_API_TOKEN']) none The standard pattern for “read from an environment variable if not given” — from ansible.module_utils.basic import env_fallback.
deprecated_aliases Mark an alias as deprecated with a removal version/date [dict(name='old', version='3.0.0')] none Emits a deprecation warning when the old name is used; pairs with collection deprecation policy.
removed_in_version/removed_at_date Mark the whole option as scheduled for removal '4.0.0' / '2027-01-01' none Warns the user the option is going away; part of clean deprecation.

A worked argument_spec showing the common keys together:

argument_spec = dict(
    path=dict(type='path', required=True),
    key=dict(type='str', required=True),
    value=dict(type='raw'),                       # accept any JSON type
    state=dict(type='str', default='present',
               choices=['present', 'absent']),
    backup=dict(type='bool', default=False),
    mode=dict(type='str'),                         # file mode, e.g. "0644"
    api_token=dict(type='str', no_log=True,
                   fallback=(env_fallback, ['MY_API_TOKEN'])),
    tags=dict(type='list', elements='str', default=[]),
    owner=dict(type='dict', options=dict(           # nested sub-spec
        name=dict(type='str', required=True),
        team=dict(type='str', default='platform'),
    )),
    pkg=dict(type='str', aliases=['package', 'name']),
)

The cross-argument constraints

Beyond per-argument rules, AnsibleModule enforces relationships between arguments. You pass these as lists of tuples (or a dict for required_by) to the constructor; violations fail the task cleanly before your logic runs. This is the second half of the validation story and a frequent exam question.

Constraint Meaning Form
mutually_exclusive At most one of these may be set [('content', 'src'), ('a', 'b', 'c')]
required_together If any one is set, all must be set [('username', 'password')]
required_one_of At least one of these must be set [('content', 'src')]
required_if If param X equals value V, then the listed params are required [('state', 'present', ('name', 'value'))] — optional 4th element True means “require any one of them”
required_by If param X is set, the listed params become required {'auth_token': ('endpoint',)}

These compose: a typical “create a thing” module declares state with choices, then required_if=[('state','present',('name',))] so name is mandatory only when creating, and mutually_exclusive=[('content','src')] so the caller picks one content source. Declaring the rules here means you never write if not module.params['name']: module.fail_json(...) by hand — AnsibleModule does it, with consistent error messages.

supports_check_mode and other constructor flags

Flag Effect Default
supports_check_mode Declares your module can run safely under --check. If False, Ansible skips the task in check mode. False (so set it True once you honour check_mode).
bypass_checks Skip required/choices/cross-arg validation (rare; for special meta-modules) False
add_file_common_args Inject the standard file args (mode, owner, group, seuser, setype, attributes, …) into your spec so you can manage file attributes with the same helpers copy uses False

add_file_common_args=True is the idiomatic way to give your module the same mode/owner/group controls as the file modules — Ansible adds those to your argument_spec and you apply them with module.set_fs_attributes_if_different(file_args, changed). The worked module below uses it.

The return contract: exit_json, fail_json, and the keys Ansible understands

When your module is done, it must end with exactly one of two calls. Both serialise a JSON object to stdout and exit — you never print() or return results yourself.

# Success — print the result JSON and exit(0)
module.exit_json(changed=True, msg="Created", item={"id": 42}, diff=diff)

# Failure — print {"failed": true, "msg": ...} and exit(1)
module.fail_json(msg="API returned 500", status=500, body=text)

exit_json(**result) is the success path; fail_json(msg=…, **extra) is the failure path (msg is required on failure — always give a useful one). Both accept arbitrary extra keys, which flow back to the play.

The keys Ansible interprets specially

Return key Meaning Notes
changed Did the module alter the system this run? The most important key — drives idempotence, --check reporting, and notify/handlers. Default False if you omit it from exit_json.
failed Did the task fail? Set automatically to True by fail_json; you rarely set it by hand on success.
msg Human-readable message Required by fail_json. Optional but recommended on success.
ansible_facts A dict of variables to inject into the host’s facts These become first-class variables (no register needed) for the rest of the play. The way a module “sets facts”.
diff Before/after for --diff A dict {'before': …, 'after': …} (text) or {'before_header':…, 'after_header':…, 'before':…, 'after':…}. Shown only with --diff.
warnings List of warnings Better added via module.warn("…"), which appends here for you.
deprecations List of deprecation notices Better added via module.deprecate("…", version="4.0.0").
invocation Echo of the module + parameters Added for you by AnsibleModule, with no_log values scrubbed. Do not set it yourself.
anything else Any other key Becomes available under the task’s registered variable — this is how you return your results (IDs, output, the resource you managed).

A subtle but important point: whatever extra keys you return are your module’s public output API. Document them in the RETURN block, keep their names and types stable across releases, and put your real result under clearly-named keys (e.g. record, output, path) rather than dumping a bag of internals. Returning ansible_facts versus a plain key is a deliberate choice: use ansible_facts when the value should become an ambient host variable (like a discovered version); use a plain key (consumed via register) when it is the result of this task that the next task will reference.

module.warn(message) surfaces a non-fatal warning in the output (and the warnings list). module.deprecate(message, version=…, date=…, collection_name=…) records a deprecation. Use both rather than building the lists by hand — they integrate with Ansible’s display and --diff/JSON output.

Idempotency and check_mode: the part that makes it a real module

This is the core skill. A module that always acts is not a module — it is command with extra steps. Genuine idempotency follows a fixed four-step shape, and check-mode support is woven through it.

The idempotency algorithm:

  1. Read the current state of the resource (read the file, query the API, inspect the service). This is a read-only operation and is always safe to do, even in check mode.
  2. Compute the desired state from the parameters.
  3. Compare. If current already equals desired, set changed=False and return — do nothing. This is the path a re-run takes; it is what produces changed=0 on the second run.
  4. If they differ, and not in check mode, make the change, then set changed=True. If in check mode, set changed=True but skip the actual write — you are predicting, not doing.

In code, the pattern is unmistakable:

result = dict(changed=False)

current = read_current_state(module)          # step 1 — read-only, always safe
desired = build_desired_state(module.params)  # step 2

if current == desired:                         # step 3 — already converged
    module.exit_json(**result)                 # changed stays False

# step 4 — drift detected
result['changed'] = True
result['diff'] = {'before': to_text(current), 'after': to_text(desired)}

if module.check_mode:                           # predict only
    module.exit_json(**result)                  # changed=True, no write performed

apply_change(module, desired)                   # the only mutating call in the module
module.exit_json(**result)

Three rules make this correct:

The absent direction follows the same shape mirrored: read current; if the resource is already gone, changed=False; if it exists, changed=True and (unless check mode) delete it.

This table summarises module behaviour across the run modes:

Scenario Normal run --check run --diff run
Already in desired state changed=False, no write changed=False, no write shows empty/no diff
Drift exists changed=True, writes changed=True, no write (prediction) shows before/after
supports_check_mode=False runs normally task skipped entirely n/a unless module returns diff
Error talking to resource fail_json fail_json (read still happens) n/a

Module placement: library/, role library/, collection plugins/modules/

A module is discovered by filename = module name (minus .py) on Ansible’s module search path. There are three places to put one, in increasing order of “production-ready”, and a clear precedence between them.

Location Scope When to use Precedence
library/ next to your playbook (or set by library / ANSIBLE_LIBRARY in ansible.cfg) The whole project Quick development, project-local modules, the lab in this lesson Adjacent library/ is searched first — overrides everything, even built-ins of the same name
A role’s library/ (roles/<role>/library/) That role (and the play using it) A module that logically belongs to one role and ships with it Auto-added to the search path while that role runs
A collection’s plugins/modules/ (<ns>/<coll>/plugins/modules/) Anywhere, addressed by FQCN ns.coll.module The production home — versioned, documented, testable, distributable via Galaxy/Automation Hub Resolved by FQCN; the modern, recommended distribution path

Precedence in one sentence: an adjacent library/ module wins over a role library/ module, which wins over a collection module, which wins over the built-in of the same name — local development overrides always take priority, which is exactly what you want while iterating. For anything you intend to share or keep, the collection (plugins/modules/my_module.py, called as namespace.collection.my_module) is the right home; it gives you a stable FQCN, room for module_utils and tests, and a release process. Use a bare library/ for prototyping and for genuinely project-private one-offs.

A note on module_utils placement that mirrors this: project-level shared code goes in a module_utils/ directory beside library/; collection shared code goes in plugins/module_utils/ and is imported as ansible_collections.<ns>.<coll>.plugins.module_utils.<name>.

DOCUMENTATION, EXAMPLES, and RETURN: the three YAML blocks

These three top-level strings turn a script into a documented module. ansible-doc my_module parses them (without importing the module) to render help; ansible-doc --snippet my_module builds a task stub from DOCUMENTATION; the sanity tests validate them against a schema. Write them carefully — they are the user-facing spec.

DOCUMENTATION

Describes the module and every option. The key fields:

Field Purpose
module The module name (must match the filename).
short_description One line, no trailing full stop, imperative.
version_added The collection version (or ansible-core version) that introduced the module.
description A list of paragraphs explaining what it does.
options A map of every parameter, each with description, type, required, default, choices, aliases, elements, and (for dict options) nested suboptions. Must mirror the argument_spec exactly — the sanity tests check this.
notes Caveats, requirements, platform notes.
requirements External libraries or binaries the module needs (e.g. python >= 3.8, requests).
author Name (@github_handle).
extends_documentation_fragment Pull in shared doc fragments (e.g. ansible.builtin.files for the file-attribute options when you use add_file_common_args).
attributes Declares capabilities like check_mode and diff_mode support and platform — the modern, machine-readable way to advertise check-mode support.
DOCUMENTATION: r'''
---
module: json_config
short_description: Manage a key in a JSON configuration file idempotently
version_added: "1.0.0"
description:
  - Ensures a given key in a JSON file is present with a value, or absent.
  - Idempotent and supports check mode and diff.
options:
  path:
    description: Absolute path to the JSON file. Created if missing.
    type: path
    required: true
  key:
    description: The top-level key to manage.
    type: str
    required: true
  value:
    description: The desired value for O(key). Required when O(state=present).
    type: raw
  state:
    description: Whether the key should be V(present) or V(absent).
    type: str
    choices: [present, absent]
    default: present
  backup:
    description: Create a timestamped backup before changing the file.
    type: bool
    default: false
attributes:
  check_mode:
    support: full
  diff_mode:
    support: full
author:
  - Vinod H (@kloudvin)
'''

(The O(...), V(...), C(...), R(...) macros are the current Ansible documentation markup for option names, values, code, and links — preferred over the older I()/C() style and validated by the sanity tests.)

EXAMPLES

A YAML list of runnable example tasks, using the FQCN. These are copy-paste fodder for users and are also schema-checked.

EXAMPLES: r'''
- name: Ensure a feature flag is enabled
  kloudvin.platform.json_config:
    path: /etc/myapp/config.json
    key: feature_x_enabled
    value: true
    state: present

- name: Remove a deprecated setting, with a backup
  kloudvin.platform.json_config:
    path: /etc/myapp/config.json
    key: legacy_mode
    state: absent
    backup: true
'''

RETURN

Documents every key your module returns (beyond the universal ones). Each entry gives description, type, returned (when it appears — always, on success, changed, etc.), and a sample.

RETURN: r'''
changed:
  description: Whether the file was modified.
  type: bool
  returned: always
  sample: true
path:
  description: The path that was managed.
  type: str
  returned: always
  sample: /etc/myapp/config.json
diff:
  description: Before/after of the file content.
  type: dict
  returned: when changed and --diff
'''

Together these three blocks mean ansible-doc kloudvin.platform.json_config produces full, accurate help — the same experience as a built-in module — and the sanity tests pass.

module_utils: sharing code across modules

When you write more than one module they will share code — an API client, auth handling, a to_native helper, common argument fragments. That shared Python belongs in module_utils, not copy-pasted into each module. Crucially, module_utils is also the only extra code Ansible bundles and ships to the target alongside your module (via AnsiBallZ) — so anything your module imports from module_utils just works on the managed node, whereas an arbitrary third-party import would not be shipped.

Placement Import path Scope
Project: module_utils/<name>.py (beside library/, path set in ansible.cfg) from ansible.module_utils.<name> import … Project-local modules
Collection: plugins/module_utils/<name>.py from ansible_collections.<ns>.<coll>.plugins.module_utils.<name> import … Any module in the collection (the production pattern)
Built-in shared utils from ansible.module_utils.basic import AnsibleModule etc. Always available (this is where AnsibleModule, env_fallback, to_native/to_text live via ansible.module_utils.common.text.converters)

A tiny example — a shared client in module_utils/myapi.py:

# module_utils/myapi.py
from __future__ import absolute_import, division, print_function
__metaclass__ = type


class MyApiClient(object):
    def __init__(self, module, endpoint, token):
        self.module = module
        self.endpoint = endpoint
        self.token = token

    def get(self, resource):
        # use module.run_command or open_url; raise/return as needed
        ...

…imported and used in a module:

from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.myapi import MyApiClient   # project-level import
# (collection: from ansible_collections.kloudvin.platform.plugins.module_utils.myapi import MyApiClient)

Two rules: (1) keep the from __future__ … / __metaclass__ boilerplate in module_utils files too (sanity tests check them), and (2) only import from the standard library and from module_utils inside a module — anything else will not be shipped to the target and will fail with ImportError there. If your module genuinely needs a third-party library (e.g. requests), guard the import and fail_json with a helpful message if it is missing:

try:
    import requests
    HAS_REQUESTS = True
except ImportError:
    HAS_REQUESTS = False
# ...
if not HAS_REQUESTS:
    module.fail_json(msg="The 'requests' library is required. Install it on the target.")

The complete worked module

Here is the whole thing — a real, idempotent, check-mode-aware module that manages one top-level key in a JSON file. It uses only the standard library (so it ships and runs anywhere Ansible runs), demonstrates the full argument_spec, the cross-argument rules, the read-compare-change idempotency loop, check-mode prediction, the diff dict, file-attribute handling via add_file_common_args, a backup, and the three documentation blocks. Drop it in library/json_config.py and the lab below runs it.

#!/usr/bin/python
# -*- coding: utf-8 -*-

# Copyright: (c) 2026, Vinod H (@kloudvin)
# GNU General Public License v3.0+ (see https://www.gnu.org/licenses/gpl-3.0.txt)

from __future__ import absolute_import, division, print_function
__metaclass__ = type

DOCUMENTATION = r'''
---
module: json_config
short_description: Manage a top-level key in a JSON configuration file idempotently
version_added: "1.0.0"
description:
  - Ensures a top-level key in a JSON file is present with a given value, or absent.
  - Reads the current file, compares against the desired state, and only writes on drift.
  - Fully idempotent; supports check mode and diff; can manage file attributes and take a backup.
options:
  path:
    description: Absolute path to the JSON file. Created (with an empty object) if missing and O(state=present).
    type: path
    required: true
  key:
    description: The top-level key to manage.
    type: str
    required: true
  value:
    description: Desired value for O(key). Required when O(state=present). May be any JSON type.
    type: raw
  state:
    description: Whether O(key) should be V(present) or V(absent).
    type: str
    choices: [present, absent]
    default: present
  backup:
    description: Create a timestamped C(.bak) copy before modifying the file.
    type: bool
    default: false
extends_documentation_fragment:
  - ansible.builtin.files
attributes:
  check_mode:
    support: full
  diff_mode:
    support: full
author:
  - Vinod H (@kloudvin)
'''

EXAMPLES = r'''
- name: Enable a feature flag
  kloudvin.platform.json_config:
    path: /etc/myapp/config.json
    key: feature_x_enabled
    value: true
    state: present
    mode: "0644"

- name: Remove a deprecated setting with a backup
  kloudvin.platform.json_config:
    path: /etc/myapp/config.json
    key: legacy_mode
    state: absent
    backup: true
'''

RETURN = r'''
changed:
  description: Whether the file was modified.
  type: bool
  returned: always
  sample: true
path:
  description: The path that was managed.
  type: str
  returned: always
  sample: /etc/myapp/config.json
backup_file:
  description: Path of the backup, if one was taken.
  type: str
  returned: when backup is true and a change was made
  sample: /etc/myapp/config.json.2026-06-15@10:00:00~
'''

import json
import os

from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.common.text.converters import to_native


def read_config(module, path):
    """Read and parse the JSON file; return a dict. Missing file => empty dict."""
    if not os.path.exists(path):
        return {}
    try:
        with open(path, 'r') as fh:
            data = json.load(fh)
    except ValueError as exc:
        module.fail_json(msg="File %s is not valid JSON: %s" % (path, to_native(exc)))
    except (IOError, OSError) as exc:
        module.fail_json(msg="Could not read %s: %s" % (path, to_native(exc)))
    if not isinstance(data, dict):
        module.fail_json(msg="Top level of %s must be a JSON object" % path)
    return data


def write_config(module, path, data):
    """Atomically write the dict back as pretty JSON, preserving Ansible's temp-file semantics."""
    tmp = module.tmpdir and os.path.join(module.tmpdir, 'json_config.tmp') or (path + '.tmp')
    try:
        with open(tmp, 'w') as fh:
            json.dump(data, fh, indent=2, sort_keys=True)
            fh.write('\n')
    except (IOError, OSError) as exc:
        module.fail_json(msg="Could not write temp file: %s" % to_native(exc))
    module.atomic_move(tmp, path)


def run_module():
    argument_spec = dict(
        path=dict(type='path', required=True),
        key=dict(type='str', required=True),
        value=dict(type='raw'),
        state=dict(type='str', default='present', choices=['present', 'absent']),
        backup=dict(type='bool', default=False),
    )

    module = AnsibleModule(
        argument_spec=argument_spec,
        supports_check_mode=True,                       # we honour check_mode below
        add_file_common_args=True,                      # gives us mode/owner/group/etc.
        required_if=[('state', 'present', ('value',))],  # value mandatory when creating
    )

    path = module.params['path']
    key = module.params['key']
    value = module.params['value']
    state = module.params['state']
    backup = module.params['backup']

    result = dict(changed=False, path=path)

    # 1. READ current state (always safe, even in check mode)
    current = read_config(module, path)
    existed = key in current

    # 2/3. COMPARE current vs desired
    if state == 'present':
        needs_change = (not existed) or (current.get(key) != value)
    else:  # absent
        needs_change = existed

    if not needs_change:
        module.exit_json(**result)                      # already converged: changed stays False

    # Build the desired dict and a diff for --diff
    desired = dict(current)
    if state == 'present':
        desired[key] = value
    else:
        desired.pop(key, None)

    result['changed'] = True
    if module._diff:
        result['diff'] = {
            'before': json.dumps(current, indent=2, sort_keys=True) + '\n',
            'after': json.dumps(desired, indent=2, sort_keys=True) + '\n',
        }

    # 4. ACT — but only past the check-mode gate
    if module.check_mode:
        module.exit_json(**result)                      # predict only; no write

    if backup and os.path.exists(path):
        result['backup_file'] = module.backup_local(path)

    write_config(module, path, desired)

    # Apply file attributes (mode/owner/group/…) the same way copy does, and fold into changed
    file_args = module.load_file_common_arguments(module.params)
    result['changed'] = module.set_fs_attributes_if_different(file_args, result['changed'])

    module.exit_json(**result)


def main():
    run_module()


if __name__ == '__main__':
    main()

Walk through what makes this a real module and not a shell-out: the argument_spec types and validates every input (with required_if making value mandatory only for present); add_file_common_args plus set_fs_attributes_if_different give it the same mode/owner/group controls as copy; the read → compare → (return unchanged | act) structure makes it idempotent so a second run reports changed=False; the module.check_mode gate sits before any write, so --check is genuinely safe; the diff (built only when module._diff is set) makes --diff show exactly what would change; module.atomic_move and module.backup_local reuse Ansible’s safe-write and backup helpers; and the three doc blocks make ansible-doc work. Notice there is no print() and no return of results — every exit goes through exit_json/fail_json.

Custom Ansible module lifecycle: Ansible serialises the task parameters and AnsiBallZ-bundles the module plus module_utils, ships it to the target's Python, the module declares its argument_spec, validates inputs, reads current state, compares to desired, honours check_mode, writes only on drift, and returns one JSON object (changed/msg/diff/ansible_facts) back to the controller

The diagram traces the full round trip: how a task’s parameters become a packaged payload, how the module validates and runs on the target, where the check-mode gate and the read-compare-change loop sit, and how the single JSON return flows back to drive register, --diff, and handlers on the control node.

Hands-on lab

You will write the worked module to library/, then drive it from a playbook to prove idempotency, check mode, and diff — entirely on localhost, cost ₹0.

1. Project layout

Create a working directory and drop the module in library/ (copy the complete worked module above into library/json_config.py):

mkdir -p ~/ansible-module-lab/library
cd ~/ansible-module-lab
# create library/json_config.py with the worked module's contents (your editor)

Add a minimal ansible.cfg so the local library/ is on the path (it is by default when adjacent, but being explicit helps):

# ansible.cfg
[defaults]
inventory = localhost,
library = ./library
host_key_checking = False

2. Confirm Ansible sees the module and its docs

ansible-doc -M ./library json_config

Expected: the rendered help — short description, the path/key/value/state/backup options, the file-attribute options (from the doc fragment), and check_mode/diff support. If ansible-doc errors, your DOCUMENTATION YAML is malformed — fix it before going further (this is exactly how the doc block earns its keep).

Generate a task snippet:

ansible-doc -M ./library --snippet json_config

3. A playbook that exercises every behaviour

Create play.yml:

---
- name: Exercise the custom json_config module
  hosts: localhost
  gather_facts: false
  vars:
    cfg: /tmp/myapp.json
  tasks:
    - name: First run  create the key (should report CHANGED)
      json_config:
        path: "{{ cfg }}"
        key: feature_x_enabled
        value: true
        state: present
        mode: "0644"
      register: first

    - name: Second run  identical (should report OK, changed=false)
      json_config:
        path: "{{ cfg }}"
        key: feature_x_enabled
        value: true
        state: present
        mode: "0644"
      register: second

    - name: Assert idempotency
      ansible.builtin.assert:
        that:
          - first.changed | bool
          - not (second.changed | bool)
        success_msg: "Idempotent: first run changed, second did not."

    - name: Show the managed path the module returned
      ansible.builtin.debug:
        var: second.path

4. Run it and read the results

ansible-playbook play.yml

Expected: the first task is changed (yellow), the second is ok (green), and the assert passes — proof of idempotency. Inspect the file:

cat /tmp/myapp.json
# {
#   "feature_x_enabled": true
# }

5. Prove check mode and diff

Run again, changing the value, in check + diff mode:

ansible-playbook play.yml --check --diff -e '{"_unused":0}' \
  --extra-vars 'cfg=/tmp/myapp.json'

Now flip the value to test prediction without writing — edit the first task’s value: true to value: false and run:

ansible-playbook play.yml --check --diff
cat /tmp/myapp.json     # STILL shows true — check mode wrote nothing

Expected: the play reports the task as changed and --diff prints a before/after showing truefalse, but the file on disk is unchanged — exactly the contract: predict, do not act. Run the same without --check and the file updates and a re-run goes green again.

6. Validation checklist

7. Cleanup

rm -rf ~/ansible-module-lab /tmp/myapp.json /tmp/myapp.json.*~

Cost note: ₹0. Everything ran against localhost with ansible-core; no cloud resources, no managed nodes, nothing to deallocate.

Common mistakes & troubleshooting

Symptom Cause Fix
Module always reports changed=true, breaks the idempotence test, fires handlers every run No read-compare step — the module just acts every time Implement the read current → compare → return unchanged if equal loop; set changed from the comparison, not unconditionally
Task is skipped under --check (shows skipping) supports_check_mode not set (defaults to False) Add supports_check_mode=True to AnsibleModule(...) and actually honour module.check_mode (gate the write)
--check made a real change A mutating call runs before the if module.check_mode: gate Move all reads first; ensure the single write sits after the check-mode exit
ansible-doc errors or shows nothing DOCUMENTATION YAML malformed, not a top-level string, or options don’t match argument_spec Validate the YAML; keep the three blocks as module-level r'''…''' strings; mirror the argument_spec exactly
ImportError on the target for a library you imported Only stdlib and module_utils are shipped; third-party imports are not Vendor the code into module_utils, or guard the import (HAS_X) and fail_json with an install hint
module not found though the file exists Wrong search path or filename ≠ module name Put it in adjacent library/ (or set library/ANSIBLE_LIBRARY); name the file <module>.py; for collections call by FQCN
Secret value leaked into -v output / invocation Missing no_log=True on the secret parameter Add no_log=True to that arg in argument_spec; never print() secrets or include them in msg
fail_json raises TypeError about missing msg fail_json called without the required msg Always pass msg="…" to fail_json; put extra context in additional keys
print() debugging breaks the module (“Unexpected output” / JSON parse error) Anything on stdout besides the final JSON corrupts the contract Never print(); use module.warn(), module.debug(), -vvv, or module.exit_json(..., debug=...)
Changes lost or partial file on crash Non-atomic write Use module.atomic_move(tmp, dest) (write temp, then move) — never write the destination in place

Best practices

Security notes

Interview & exam questions

  1. Where does a module run, and how does that differ from a plugin? A module runs on the managed node (or the control node acting as target for local connections): Ansible bundles it via AnsiBallZ, ships it, runs it with the target’s Python, and reads one JSON line from stdout. A plugin runs in the Ansible process on the control node, extending Ansible itself (filter, lookup, callback, connection). “Does work on the target → module; transforms data/hooks Ansible → plugin.”
  2. What is the module’s return contract, and which keys does Ansible interpret specially? The module prints one JSON object. Ansible specially understands changed, failed, msg, ansible_facts, diff, warnings, deprecations, and invocation; every other key flows back under the task’s register. You finish with module.exit_json(**result) (success) or module.fail_json(msg=…) (failure) — never print()/return.
  3. How do you make a module idempotent? Read the current state, compare it to the desired state from the params, and act only if they differ, setting changed from the comparison. If already converged, return changed=False. This is what makes a second run report changed=0.
  4. What does supports_check_mode=True do, and what must you also do? It tells Ansible the module is safe under --check (without it, the task is skipped in check mode). You must also honour module.check_mode: compute the would-be changed and build the diff, but gate every write behind if module.check_mode: module.exit_json(...) so nothing is actually changed.
  5. List five argument_spec keys and what each does. type (declare/coerce the type), required (must be supplied), default (value when omitted), choices (enumerated valid values), no_log (scrub from output/logs); plus aliases, elements (item type for lists), options (nested sub-spec for dicts), and fallback (e.g. env_fallback).
  6. Name the cross-argument constraints and give an example use. mutually_exclusive (at most one — e.g. content/src), required_together (all-or-none — username/password), required_one_of (at least one — content/src), required_if (X==V ⇒ others required — state==present ⇒ name), required_by (X set ⇒ others required — auth_token ⇒ endpoint). They let AnsibleModule validate relationships so you don’t hand-code the checks.
  7. What are the three documentation blocks and why are they top-level strings? DOCUMENTATION (the module + every option), EXAMPLES (runnable FQCN tasks), and RETURN (every returned key). They are module-level assignments (raw strings) so ansible-doc can extract them by parsing the file without importing it; the sanity tests validate them, and DOCUMENTATION.options must mirror argument_spec.
  8. Where can a module live, and what is the precedence? Adjacent library/ (project-wide; searched first, overrides built-ins), a role’s library/ (while that role runs), and a collection’s plugins/modules/ (production, addressed by FQCN). Local library/ wins over role library/ wins over collection wins over built-in — local overrides take priority.
  9. What is module_utils for, and what is special about it? Shared Python imported by modules. It is the only extra code AnsiBallZ ships to the target alongside the module, so imports from module_utils work on the managed node (arbitrary third-party imports do not). Project: module_utils/<name>.pyfrom ansible.module_utils.<name> import …; collection: plugins/module_utils/from ansible_collections.<ns>.<coll>.plugins.module_utils.<name> import ….
  10. How do you return a value that becomes a host variable versus one consumed by register? Return it under ansible_facts to inject it as an ambient host variable (no register needed); return it under any other key to expose it through the task’s **register**ed result. Use ansible_facts for discovered/ambient state, a plain key for this task’s result.
  11. How should a module shell out, and why does it matter for security? Use module.run_command([...] ) with an argument list (not a shell string) — it handles quoting/env, returns (rc, stdout, stderr), and avoids shell injection from untrusted params. Avoid use_unsafe_shell=True. For secrets, set no_log=True so values are scrubbed.
  12. You added print("debug") and the module breaks with a JSON error — why? The return contract requires only the final JSON on stdout. Any extra stdout corrupts it. Use module.warn(), module.debug(), -vvv, or return a debug key via exit_json instead of print().

Quick check

  1. After your module determines the system is already in the desired state, what value must changed be, and what does that prove on a second run?
  2. Which constructor flag must you set so --check does not skip your module, and what must you do in the code to back it up?
  3. Name the two methods a module must use to finish — one for success, one for failure — and the one argument the failure method requires.
  4. You import requests at the top of a module and it works on the control node but fails on the target. Why, and what is the fix?
  5. Which return key turns a value into an ambient host variable that the rest of the play can use without register?

Answers

  1. changed=False — and a second, identical run reporting changed=0 is the proof of idempotency. Set changed from the read-vs-desired comparison, never unconditionally.
  2. supports_check_mode=True in AnsibleModule(...). You must also honour module.check_mode: gate every mutating call behind if module.check_mode: module.exit_json(**result) so check mode predicts (changed=True if it would change) but never writes.
  3. module.exit_json(**result) for success and module.fail_json(msg=…) for failure; fail_json requires msg. Never print() or return results.
  4. Ansible ships only the standard library and module_utils to the target (via AnsiBallZ); arbitrary third-party imports are not bundled, so requests is absent on the managed node. Fix: vendor the needed code into module_utils, or guard the import (try/except ImportError, HAS_REQUESTS) and fail_json with an install hint.
  5. ansible_facts — returning a dict under ansible_facts injects those keys as host variables for the rest of the play (no register needed).

Exercise

Extend the worked json_config module into a small but production-shaped piece of work (cost ₹0, all on localhost). (a) Add a new parameter present_only_if_absent of type='bool' default false; when true and state: present, the module must not overwrite an existing key (it should report changed=False if the key already exists with any value) — implement this with the read-compare logic, not a post-hoc check. (b) Add a value validation: if state: present and the existing value is a dict but the new value is a scalar, fail_json with a clear message — and prove the message is helpful by triggering it. © Add full diff output for the absent path and confirm --diff shows the key being removed. (d) Move the read_config/write_config helpers into module_utils/json_state.py and import them, proving the module still runs (this exercises the module_utils shipping mechanism). (e) Write a second playbook that runs the module twice with --check and uses ansible.builtin.assert to prove the file on disk never changed in check mode. (f) Run ansible-doc -M ./library json_config after every change and keep DOCUMENTATION.options in sync with the argument_spec — note in one sentence what ansible-doc does when they drift. (g) Clean up. In two sentences, explain why the new behaviour belongs in the read-compare step (so changed stays truthful and check mode keeps working) rather than being bolted on after the write.

Certification mapping

Glossary

Next steps

You can now write a real, idempotent, check-mode-aware custom module end to end — choosing a module over a role/command/script/uri; instantiating AnsibleModule with a complete argument_spec and the cross-argument rules; returning results through the exit_json/fail_json contract (changed, diff, ansible_facts); implementing the read → compare → change-only-on-drift loop and honouring module.check_mode; placing the module in library/, a role’s library/, or a collection’s plugins/modules/; writing the DOCUMENTATION/EXAMPLES/RETURN blocks; and sharing code via module_utils, with debugging and sanity/unit testing on the side. The natural next move is the other half of “developing Ansible”: Ansible plugins, in depth — filter, lookup, callback, and connection plugins, the code that (unlike modules) runs on the control node and extends Ansible itself, with worked filter and lookup examples. From there, package what you have built: revisit roles & collections to bundle your module and module_utils into a versioned, FQCN-addressed collection ready for Galaxy or Automation Hub.

ansiblecustom-modulespythonargument-specidempotencyEX374
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments