Module 10 — Custom Modules & Plugins

Updated 20 August 2026

Module 10 · Custom Modules & Plugins

Extending Ansible when nothing existing fits — and, just as importantly, recognising the far more common case where something already does.

🧠 concept → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (answers hidden)

Prerequisite: Modules 01–09. You need the execution model from Module 01 Part B6 and the collection layout from Module 06.


Part A · When to write one, and when not to

A1 · The decision tree

The analogy. Think of needing a tool you do not own.

First you check the drawer, because you probably already have one. Then you consider whether an ordinary screwdriver would do the job. Then whether you could borrow one for the afternoon.

Forging your own is the last option, not the first — and once you have made it, you own it forever: sharpening it, storing it, replacing it when it breaks.

Writing a custom module is forging the tool. Sometimes exactly right, and the reason the honest interview answer is "rarely, and deliberately".

Diagram source
flowchart TD
    A["I need something<br>Ansible does not do"] --> B{"Search the module index.<br>Does one exist?"}
    B -->|"Yes"| B1["Use it. Stop here."]
    B -->|"No"| C{"Is it an HTTP API?"}
    C -->|"Yes"| C1["Use the uri module<br>wrapped in a role"]
    C -->|"No"| D{"Is it data transformation<br>rather than an action?"}
    D -->|"Yes"| D1["Write a FILTER plugin<br>far simpler than a module"]
    D -->|"No"| E{"Is it fetching data<br>on the control node?"}
    E -->|"Yes"| E1["Write a LOOKUP plugin"]
    E -->|"No"| F{"Used more than<br>a few times?"}
    F -->|"No"| F1["A guarded command task<br>is honest and cheaper"]
    F -->|"Yes"| G["Write a MODULE"]
    style B1 fill:#D1FAE5,stroke:#059669,stroke-width:2px
    style G fill:#DDD6FE,stroke:#7C3AED,stroke-width:2px
The mature answer to "have you written a custom module?" is not just yes or no. It is: "Rarely, and deliberately. Most of the time the right answer is an existing module, the uri module for an API, or a filter plugin for data shaping. I write a module when we need idempotency and check-mode support against a system with no existing coverage — because those two properties are exactly what a command task cannot give you."

That framing shows judgement. Enthusiasm for writing modules reads as inexperience.

A2 · What a module buys you over command

The analogy. Think of two builders quoting for a job.

The first says "I'll start Monday" and begins knocking walls down. You find out what he did when you get home.

The second walks the house first and says: "That wall is already the right size, so I'll leave it. This one needs moving. Here's exactly what I'd change — shall I proceed?"

The second builder is a real module. Checking before acting is idempotency; describing the work without doing it is check mode. Those two behaviours are the entire reason a module is worth writing instead of using command.

command / shellA real module
Idempotency❌ You bolt it on with creates or changed_when✅ Built in — it inspects state first
Check mode❌ Skips entirely — your dry run is worthless✅ Reports what would change
Argument validation❌ None✅ Types, choices, required, mutually exclusive
Structured return❌ You parse stdout✅ JSON with documented keys
Secret handling❌ Arguments appear in logsno_log=True censors automatically
Documentation❌ A comment, if you are luckyansible-doc renders it
Check mode and idempotency are the two that justify the effort. Everything else is convenience. If your team runs --check --diff before production changes — and Module 01 Part D5 argued they should — then a command task is a hole in that safety net, because it skips in check mode and tells you nothing.

🎯 Interview questions — When to extend Ansible

Q. When would you write a custom module?

When there is no existing module for the system, the operation needs to be idempotent, and it needs to support check mode — those two properties are what a command task fundamentally cannot provide.

Before that: search the module index, consider the uri module for anything HTTP, consider a filter plugin if it is data transformation rather than an action, and consider whether a guarded command task used twice is honestly cheaper than a module you must then test and maintain.

Q. What does a module give you that command does not?

Built-in idempotency — it inspects current state and acts only if needed. Check-mode support, where command skips entirely and makes your dry run worthless. Argument validation with types, choices and required fields. A structured JSON return instead of parsed stdout. Automatic secret censoring via no_log=True in the argument spec. And documentation ansible-doc can render.


Part B · Anatomy of a module

B1 · The complete skeleton

The analogy. Think about the careful builder's actual order of work. First he looks at the wall — if it is already right, he says so and charges nothing. If it does need changing but you only asked for a quote, he describes the change and puts the hammer down. Only after both of those does he swing it.

Look, then describe, then act — and that order is the shape of every module skeleton below. It is not a stylistic preference: a builder who swings first and asks afterwards has knocked your wall down during what you thought was a quote, which in code means a module that ignores check_mode and changes production during a --check run.

python
#!/usr/bin/python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, print_function
__metaclass__ = type

DOCUMENTATION = r'''
---
module: tenant
short_description: Manage platform tenants
version_added: "1.0.0"
description:
  - Creates, updates and removes tenants in the internal platform API.
options:
  name:
    description: Tenant name.
    required: true
    type: str
  quota_gb:
    description: Storage quota in gigabytes.
    required: false
    type: int
    default: 10
  state:
    description: Desired state.
    type: str
    choices: [present, absent]
    default: present
  api_token:
    description: API token for the platform.
    required: true
    type: str
author:
  - Zaeem Mazhar (@zaeem)
'''

EXAMPLES = r'''
- name: Ensure a tenant exists
  mycompany.platform.tenant:
    name: acme
    quota_gb: 50
    api_token: "{{ vault_platform_token }}"

- name: Remove a tenant
  mycompany.platform.tenant:
    name: acme
    state: absent
    api_token: "{{ vault_platform_token }}"
'''

RETURN = r'''
tenant:
  description: The tenant object after the change.
  returned: when state is present
  type: dict
  sample: {"name": "acme", "quota_gb": 50, "id": "t-123"}
changed:
  description: Whether anything was modified.
  returned: always
  type: bool
'''

from ansible.module_utils.basic import AnsibleModule


def get_tenant(module, name):
    """Return the tenant dict, or None if it does not exist."""
    # real implementation would call the API here
    return None


def run_module():
    argument_spec = dict(
        name=dict(type='str', required=True),
        quota_gb=dict(type='int', default=10),
        state=dict(type='str', default='present', choices=['present', 'absent']),
        api_token=dict(type='str', required=True, no_log=True),
    )

    module = AnsibleModule(
        argument_spec=argument_spec,
        supports_check_mode=True,
        required_if=[('state', 'present', ['quota_gb'])],
    )

    name = module.params['name']
    state = module.params['state']
    result = dict(changed=False)

    current = get_tenant(module, name)

    # 1 - already in the desired state: do nothing, report ok
    if state == 'present' and current and current['quota_gb'] == module.params['quota_gb']:
        result['tenant'] = current
        module.exit_json(**result)

    if state == 'absent' and not current:
        module.exit_json(**result)

    # 2 - a change IS needed. In check mode, say so and stop.
    if module.check_mode:
        result['changed'] = True
        module.exit_json(**result)

    # 3 - actually make the change
    try:
        if state == 'present':
            result['tenant'] = {'name': name, 'quota_gb': module.params['quota_gb']}
        result['changed'] = True
    except Exception as e:
        module.fail_json(msg="Failed to manage tenant %s: %s" % (name, str(e)), **result)

    module.exit_json(**result)


def main():
    run_module()


if __name__ == '__main__':
    main()
The three-step shape is the whole design, and it is what interviewers probe:

1. Check current state first. If it already matches, exit_json with changed=False. This is idempotency, and it must come before anything else.

2. Handle check_mode before acting. If a change is needed and module.check_mode is true, report changed=True and exit without doing anything.

3. Only then make the change.

Get that order wrong — acting before the check-mode guard — and your module modifies production during a dry run.

B2 · The argument spec

The analogy. Think of a clerk who checks your form before you leave the counter: date in the date box, a real option ticked, the required fields actually filled in. It takes thirty seconds and you fix it there and then. Without that glance, the form travels through three departments before anyone notices, and by that point nobody remembers what you meant.

The argument_spec is the clerk — types, required fields, allowed choices and defaults, all checked before your module runs a single line. And no_log=True is the clerk not reading your account number aloud across the counter, which is the same distinction Module 07 drew between the safe and the cashier's voice.

KeyPurpose
typestr, int, bool, list, dict, path, raw, json. Ansible casts for you
requiredFail before running if absent
defaultApplied when omitted
choicesEnumerated valid values — fails with a clear message otherwise
no_log=True⭐ Censors this value everywhere — output, -vvv, logs. Mandatory on any secret
elementsType of items inside a list
aliasesAlternative parameter names
python
module = AnsibleModule(
    argument_spec=argument_spec,
    supports_check_mode=True,
    required_if=[('state', 'present', ['quota_gb'])],   # conditional requirement
    required_together=[['username', 'password']],       # all or none
    required_one_of=[['name', 'id']],                   # at least one
    mutually_exclusive=[['name', 'id']],                # at most one
)
no_log=True on the api_token parameter is not optional. Without it the token appears in task output, in -vvv, and in the AnsiballZ payload sitting in the temp directory. With it, Ansible replaces it with VALUE_SPECIFIED_IN_NO_LOG_PARAMETER everywhere automatically — you do not have to remember no_log on every task that calls your module.

This is a genuine advantage of a module over command, and it is worth naming as one.

B3 · Returning results

python
module.exit_json(changed=True, tenant={'name': 'acme'}, msg="Created")
module.fail_json(msg="Could not reach the API", status_code=503)

# Useful helpers on the module object
rc, stdout, stderr = module.run_command(['/usr/bin/thing', '--flag'], check_rc=True)
module.warn("quota_gb below 5 is not recommended")
module.deprecate("The 'size' option is deprecated", version="2.0.0")
module.check_mode          # bool
module.params              # the validated, type-cast parameters
A module communicates only through JSON on stdout. Never print(), never write to stdout directly — anything other than the single JSON document breaks the parser on the control node and produces a baffling "module failed to parse output" error.

Use module.warn() for messages, and module.fail_json() rather than raising an uncaught exception, so the failure is structured and readable.

🧪 Exercise B3.1 — Run a module by hand, with no Ansible at all
bash
mkdir -p library
# save the skeleton above as library/tenant.py

# A module reads JSON args from a file passed as argv[1]
cat > /tmp/args.json <<'EOF'
{"ANSIBLE_MODULE_ARGS": {"name": "acme", "quota_gb": 50, "api_token": "secret123", "state": "present"}}
EOF

python3 library/tenant.py /tmp/args.json

# Now omit a required argument
echo '{"ANSIBLE_MODULE_ARGS": {"quota_gb": 50}}' > /tmp/bad.json
python3 library/tenant.py /tmp/bad.json

# And an invalid choice
echo '{"ANSIBLE_MODULE_ARGS": {"name": "x", "api_token": "t", "state": "maybe"}}' > /tmp/bad2.json
python3 library/tenant.py /tmp/bad2.json
Expected result — click to reveal
json
{"changed": true, "tenant": {"name": "acme", "quota_gb": 50}, "invocation": {"module_args": {"name": "acme", "quota_gb": 50, "state": "present", "api_token": "VALUE_SPECIFIED_IN_NO_LOG_PARAMETER"}}}
json
{"failed": true, "msg": "missing required arguments: api_token, name"}
json
{"failed": true, "msg": "value of state must be one of: present, absent, got: maybe"}

Three things worth noticing.

  1. api_token came back as VALUE_SPECIFIED_IN_NO_LOG_PARAMETER — in the invocation echo, automatically, because of no_log=True. That censoring happens for every caller of your module without them doing anything.
  2. Validation failed before any code ran. The missing-argument and invalid-choice errors came from AnsibleModule itself, not from your logic — which is the argument spec earning its keep.
  3. A module is just a Python script that reads JSON and prints JSON. Running it directly like this is the fastest possible development loop — no inventory, no SSH, no playbook. It is exactly the mechanism from Module 01 Part B6, seen from the inside.

Now try "_ansible_check_mode": true in the args and confirm your module reports changed: true without acting.

🎯 Interview questions — Module anatomy

Q. Walk me through the structure of a custom module.

Shebang, then DOCUMENTATION, EXAMPLES and RETURN as YAML strings — these are what ansible-doc renders and sanity tests validate. Then import AnsibleModule from ansible.module_utils.basic.

In run_module: define the argument_spec, instantiate AnsibleModule with supports_check_mode=True, then the three-step body — check current state and exit unchanged if it matches; handle check mode before acting; then make the change. Return with exit_json or fail_json.

The module communicates only through a single JSON document on stdout.

Q. How do you support check mode correctly?

supports_check_mode=True when constructing AnsibleModule, then guard the mutating code with if module.check_mode: module.exit_json(changed=True) after determining that a change is needed but before performing it.

Order matters absolutely: put the guard after the mutation and your module changes production during a dry run.

If a module declares supports_check_mode=False, Ansible skips it entirely in check mode rather than risking it.

Q. How do you handle a secret parameter?

no_log=True in the argument spec for that option. Ansible then replaces the value with VALUE_SPECIFIED_IN_NO_LOG_PARAMETER in all output, verbose logs and the invocation echo — automatically, for every caller, without them needing no_log on their task.

That automatic censoring is one of the concrete advantages of a module over a command task.

Q. Why must a module never print()?

Because it communicates with the control node through exactly one JSON document on stdout. Any extra output corrupts that and produces a confusing "failed to parse output" error.

Use module.warn() for messages, module.fail_json() for errors, and module.exit_json() for the result.

Q. How do you test a module during development?

Run it directly: python3 library/mymodule.py /tmp/args.json where the file contains {"ANSIBLE_MODULE_ARGS": {...}}. No inventory, no SSH, no playbook — the fastest possible loop, and it exercises the real argument validation.

Add "_ansible_check_mode": true to test the check-mode path.

Then ansible-test sanity, ansible-test units and ansible-test integration from inside the collection for the real suite.


Part C · The other plugin types

C1 · Where each type runs — the distinction that matters

The analogy. Think about who does what when you send a parcel. You pack the box, write the label and choose what goes in — all at your own table. Only the box itself travels, and the recipient never sees your table, your pen, or the drawer you took the packing tape from.

Modules are the box: the only thing that travels and runs at the far end. Filters, lookups, templates and inventory plugins all happen at your table, on the control node. That is why lookup('file', …) reads your filing cabinet and not the server's — the same point Module 04 A2 made about templates, and the single most common misunderstanding about how Ansible executes.

Diagram source
flowchart TD
    A["Plugin types"] --> B["Run on the CONTROL NODE"]
    A --> C["Run on the MANAGED NODE"]
    B --> B1["filter  - transform data in a template"]
    B --> B2["lookup  - fetch data from files, APIs, env"]
    B --> B3["test    - is x valid?"]
    B --> B4["callback - format output, send notifications"]
    B --> B5["inventory - build the host list"]
    B --> B6["action  - wraps a module, can do local work first"]
    C --> C1["module  - the actual work, pushed and executed"]
    style B fill:#DDD6FE,stroke:#7C3AED,stroke-width:2px
    style C1 fill:#FEF3C7,stroke:#D97706,stroke-width:2px
Only modules run on the target. Everything else runs on the control node. That single sentence answers most plugin questions, and it explains why lookup('file', '/etc/hostname') reads the control node's file — the point made in Module 02 Part D4 and Module 04 Part A2, now with the underlying reason.

C2 · Filter plugins — the easiest and most useful

python
# plugins/filter/network.py   (or filter_plugins/network.py beside a playbook)
from __future__ import absolute_import, division, print_function
__metaclass__ = type


def to_cidr(netmask):
    """255.255.255.0 -> 24"""
    return sum(bin(int(x)).count('1') for x in netmask.split('.'))


def env_short(environment):
    """production -> prd, staging -> stg"""
    mapping = {'production': 'prd', 'staging': 'stg', 'development': 'dev'}
    return mapping.get(environment, environment[:3])


class FilterModule(object):
    def filters(self):
        return {
            'to_cidr': to_cidr,
            'env_short': env_short,
        }
yaml
- ansible.builtin.debug:
    msg: "{{ '255.255.255.0' | mycompany.platform.to_cidr }}"     # -> 24
    # or unqualified when in filter_plugins/ beside the playbook:
    # msg: "{{ '255.255.255.0' | to_cidr }}"
A filter plugin is about fifteen lines and solves a large share of "I need a custom module" requests. If the task is transform this value, it is a filter, not a module — no idempotency to worry about, no check mode, no remote execution, and it is trivially unit-testable as a plain Python function.

C3 · Lookup plugins

python
# plugins/lookup/cmdb.py
from __future__ import absolute_import, division, print_function
__metaclass__ = type

DOCUMENTATION = r'''
name: cmdb
short_description: Look up a host attribute from the internal CMDB
description:
  - Queries the internal CMDB API and returns the requested attribute.
options:
  _terms:
    description: Hostnames to look up.
    required: true
'''

from ansible.plugins.lookup import LookupBase
from ansible.errors import AnsibleError


class LookupModule(LookupBase):

    def run(self, terms, variables=None, **kwargs):
        self.set_options(var_options=variables, direct=kwargs)
        results = []
        for term in terms:
            try:
                results.append(self._query_cmdb(term))
            except Exception as e:
                raise AnsibleError("CMDB lookup failed for %s: %s" % (term, e))
        return results

    def _query_cmdb(self, hostname):
        return {"owner": "platform-team", "tier": "web"}
yaml
- ansible.builtin.debug:
    msg: "{{ lookup('mycompany.platform.cmdb', inventory_hostname) }}"
A lookup always returns a list, even for a single term — that is the contract, and forgetting it produces confusing results.

And it runs on the control node, once per host when used inside a task that loops over hosts. On a large fleet that is a lot of API calls, which is why the Module 07 advice applies: fetch once with run_once and delegate_to: localhost, then read from hostvars.

C4 · Action plugins versus modules

The analogy. Think of that wedding invitation from Module 04 one more time. You write the guest's name in at your own table and then post the finished card. You could not post a blank card and a pen and expect the guest to fill it in, because the guest list is on your table, not theirs. The filling-in has to happen at your end; only the posting happens at theirs.

That is exactly why template needs an action plugin as well as a module. The action plugin renders the template locally, where the .j2 file and your variables actually live, and then hands the finished text to copy to deliver. One job at your table, one job at theirs — and that split is what an action plugin is.

TypeBehaviour
ModulePushed to the target and executed there. Has no access to control-node files
Action pluginRuns on the control node first, can do local work, then usually calls a module on the target
template is the canonical example. Its action plugin renders the Jinja2 template locally — where your variables and template files live — and then hands the finished text to the copy module for transfer. A pure module could not do this, because the template file is on the control node and the module runs on the target.

You will rarely write an action plugin. Being able to explain why template needs one is the answer that shows you understand the architecture.

C5 · Where plugins live

plain text
# Beside a playbook - quick and local
project/
  library/                 <- modules
  filter_plugins/
  lookup_plugins/
  callback_plugins/

# Inside a role - scoped to that role
roles/myrole/
  library/
  filter_plugins/

# Inside a collection - the modern, shareable way  ⭐
mycompany/platform/plugins/
  modules/
  module_utils/
  filter/
  lookup/
  action/
  callback/
All three work; the collection is the one to use for anything you will keep. library/ beside a playbook is fine for a one-off, and a role's library/ is fine for something only that role needs. A collection gets you FQCN addressing, versioning, ansible-doc, and the ability to share it — which is the Module 06 argument, applied to code rather than roles.
🧪 Exercise C5.1 — Write and use a filter plugin in five minutes
bash
mkdir -p filter_plugins
cat > filter_plugins/custom.py <<'PYEOF'
from __future__ import absolute_import, division, print_function
__metaclass__ = type


def to_cidr(netmask):
    return sum(bin(int(x)).count('1') for x in netmask.split('.'))


def env_short(environment):
    return {'production': 'prd', 'staging': 'stg'}.get(environment, environment[:3])


class FilterModule(object):
    def filters(self):
        return {'to_cidr': to_cidr, 'env_short': env_short}
PYEOF

cat > filtertest.yml <<'EOF'
---
- hosts: localhost
  gather_facts: false
  vars:
    netmask: "255.255.254.0"
    env: "production"
  tasks:
    - ansible.builtin.debug:
        msg:
          - "{{ netmask }} is a /{{ netmask | to_cidr }}"
          - "{{ env }} short name is {{ env | env_short }}"
          - "chained: {{ env | env_short | upper }}"
EOF

ansible-playbook filtertest.yml
ansible-doc -t filter -l 2>/dev/null | grep -E 'to_cidr|env_short' || echo "not documented"
Expected result — click to reveal
plain text
ok: [localhost] => {
    "msg": [
        "255.255.254.0 is a /23",
        "production short name is prd",
        "chained: PRD"
    ]
}
plain text
not documented

Twelve lines of Python and it works immediately — no installation, no collection, no restart. Ansible discovers filter_plugins/ beside the playbook automatically.

The third message shows filters chain with built-ins, because a custom filter is just a Python function in the same pipeline as upper. There is nothing special about it once registered.

Note the second command returned nothing. Plugins in filter_plugins/ beside a playbook are not documented by ansible-doc and have no FQCN — so two projects each defining to_cidr would collide, exactly the ambiguity Module 01 Part A4 described.

That is the argument for putting it in a collection as plugins/filter/: you gain mycompany.platform.to_cidr, versioning, and documentation. Start here for speed; move it there once you keep it.

🎯 Interview questions — Plugin types

Q. Which plugin types run on the control node and which on the target?

Only modules run on the managed node. Everything else — filter, lookup, test, callback, inventory, action, connection, strategy — runs on the control node.

That is why lookup('file', ...) reads the control node's filesystem and why a filter can never inspect a remote host.

Q. What is the difference between an action plugin and a module?

A module is pushed to the target and executed there, with no access to control-node files. An action plugin runs on the control node first, can do local work, and usually then invokes a module on the target.

template is the canonical example: its action plugin renders the Jinja2 locally — where the template file and your variables live — then hands the finished text to the copy module. A pure module could not do that.

Q. When is a filter plugin the right answer instead of a module?

Whenever the task is transforming a value rather than changing system state. No idempotency concerns, no check mode, no remote execution — about fifteen lines of Python, and unit-testable as a plain function.

A large share of "we need a custom module" requests are really filter plugins.

Q. What is the contract for a lookup plugin's return value?

It must return a list, even for a single term. Forgetting that produces confusing results downstream.

It also runs on the control node once per host when used in a per-host task, so on a large fleet you fetch once with run_once and delegate_to: localhost and read the result from hostvars.


Part D · Testing and distribution

D1 · ansible-test

The analogy. Think of testing a recipe you intend to publish. You do not cook it once and send it straight to the printer. You cook it twice — because the real test is whether following your own written words a second time produces the same dish. If the second attempt comes out different, the recipe contains something you did from habit and never wrote down.

Run-it-twice is the most valuable test you can write for a module, and it catches the commonest real bug by far: acting unconditionally instead of checking the current state first. It is the six eggs from Module 01, now as a test you can actually run in CI.

Run from inside the collection directory:

bash
cd ~/collections/ansible_collections/mycompany/platform

ansible-test sanity --docker default          # ⭐ docs, imports, pep8, validate-modules
ansible-test sanity --docker --test validate-modules
ansible-test units --docker default           # unit tests under tests/unit/
ansible-test integration --docker default     # real runs under tests/integration/
ansible-test integration tenant --docker      # one target only
Test typeChecks
sanityDOCUMENTATION is valid and matches the argument_spec, imports work, PEP8, no shebang mistakes. Cheapest and catches the most
unitsYour functions in isolation, with the API mocked
integrationThe module actually running, including an idempotency check — run twice, assert changed=false the second time
validate-modules inside the sanity suite is the one to name. It verifies that your DOCUMENTATION block matches the real argument_spec — every option documented, types agreeing, defaults consistent. It catches the most common maintenance failure: someone adds a parameter and forgets to document it, so ansible-doc starts lying to users.
yaml
# tests/integration/targets/tenant/tasks/main.yml
---
- name: Create a tenant
  mycompany.platform.tenant:
    name: test-tenant
    api_token: "{{ test_token }}"
  register: first

- name: Create the same tenant again
  mycompany.platform.tenant:
    name: test-tenant
    api_token: "{{ test_token }}"
  register: second

- name: Assert idempotency
  ansible.builtin.assert:
    that:
      - first is changed
      - second is not changed
    fail_msg: "Module is not idempotent - second run reported changed"
That three-task pattern is the single most valuable integration test you can write. Run, run again, assert the second was unchanged. It is the definition of idempotency turned into an executable check, and it catches the most common real bug in a hand-written module — acting unconditionally instead of inspecting state first.

D2 · Documentation is tested, so write it properly

bash
ansible-doc mycompany.platform.tenant          # ⭐ renders your DOCUMENTATION block
ansible-doc -s mycompany.platform.tenant       # ⭐ paste-ready task snippet
ansible-doc -t filter mycompany.platform.to_cidr
The DOCUMENTATION string is not decorative. It generates ansible-doc output, it is validated by sanity tests against the argument spec, and it becomes the module's page if the collection is published. A module with a thin docstring is one nobody else can use without reading the source.

Part E · Putting it together

E1 · Production practice

HabitWhy
Search the module index before writing anythingNine times out of ten it already exists, and yours will be worse maintained
Filter plugin for data transformation, module for state changeA filter is fifteen lines with no idempotency or check-mode concerns
Check current state first, then check mode, then actWrong order means your module modifies production during a dry run
supports_check_mode=True and honour itDeclaring False makes Ansible skip the module in --check entirely
no_log=True on every secret parameterCensors it automatically for every caller, in output and -vvv
Never print()exit_json, fail_json, warnStray stdout corrupts the single JSON document and breaks the parser
fail_json rather than an uncaught exceptionStructured, readable failure instead of a Python traceback
Write the run-twice idempotency integration testCatches the most common real bug in a hand-written module
Keep DOCUMENTATION in step with argument_specvalidate-modules fails otherwise, and ansible-doc starts lying to users
Put anything you keep in a collection, not library/FQCN, versioning, ansible-doc, no name collisions
Develop by running the module directly with an args JSON fileNo inventory, no SSH — the fastest possible loop

E2 · Capstone exercise

Attempt this without looking anything up. It exercises the argument spec, idempotency, check mode, secret handling and testing together.

Brief. Write a module feature_flag that manages a flag in a JSON file on the target:

  1. Options: name (required), enabled (bool, default false), path (path, default /etc/app/flags.json), api_token (required, secret)
  2. Must be idempotent — no change when the flag already has the desired value
  3. Must support check mode correctly
  4. The token must never appear in any output
  5. Must fail cleanly, not with a traceback, if the file is unreadable
  6. Must return the resulting flag state
  7. Write the integration test that proves requirement 2
Model answer — attempt it first, then click
python
#!/usr/bin/python
from __future__ import absolute_import, division, print_function
__metaclass__ = type

DOCUMENTATION = r'''
---
module: feature_flag
short_description: Manage a feature flag in a JSON file
version_added: "1.0.0"
description:
  - Sets a named feature flag to enabled or disabled in a JSON file on the target.
options:
  name:
    description: Flag name.
    required: true
    type: str
  enabled:
    description: Desired flag state.
    type: bool
    default: false
  path:
    description: Path to the JSON flags file.
    type: path
    default: /etc/app/flags.json
  api_token:
    description: Token used to report the change to the platform API.
    required: true
    type: str
author:
  - Zaeem Mazhar (@zaeem)
'''

EXAMPLES = r'''
- name: Enable the new checkout flow
  mycompany.platform.feature_flag:
    name: new_checkout
    enabled: true
    api_token: "{{ vault_platform_token }}"
'''

RETURN = r'''
flag:
  description: The flag state after the run.
  returned: always
  type: dict
  sample: {"name": "new_checkout", "enabled": true}
'''

import json
import os

from ansible.module_utils.basic import AnsibleModule


def read_flags(module, path):
    if not os.path.exists(path):
        return {}
    try:
        with open(path, 'r') as fh:
            return json.load(fh)
    except (IOError, OSError, ValueError) as e:          # requirement 5
        module.fail_json(msg="Cannot read flags file %s: %s" % (path, str(e)))


def run_module():
    argument_spec = dict(                                 # requirement 1
        name=dict(type='str', required=True),
        enabled=dict(type='bool', default=False),
        path=dict(type='path', default='/etc/app/flags.json'),
        api_token=dict(type='str', required=True, no_log=True),   # requirement 4
    )

    module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True)

    name = module.params['name']
    enabled = module.params['enabled']
    path = module.params['path']

    flags = read_flags(module, path)
    current = flags.get(name)

    result = dict(changed=False, flag={'name': name, 'enabled': enabled})   # requirement 6

    # requirement 2 - already correct, do nothing
    if current == enabled:
        result['flag']['enabled'] = current
        module.exit_json(**result)

    # requirement 3 - a change is needed; in check mode stop here
    if module.check_mode:
        result['changed'] = True
        module.exit_json(**result)

    flags[name] = enabled
    try:
        dirname = os.path.dirname(path)
        if dirname and not os.path.isdir(dirname):
            os.makedirs(dirname)
        with open(path, 'w') as fh:
            json.dump(flags, fh, indent=2, sort_keys=True)
    except (IOError, OSError) as e:                       # requirement 5
        module.fail_json(msg="Cannot write flags file %s: %s" % (path, str(e)), **result)

    result['changed'] = True
    module.exit_json(**result)


def main():
    run_module()


if __name__ == '__main__':
    main()

Requirement 7 — the integration test:

yaml
---
- name: Set the flag
  mycompany.platform.feature_flag:
    name: test_flag
    enabled: true
    path: /tmp/test-flags.json
    api_token: dummy
  register: first

- name: Set the same flag again
  mycompany.platform.feature_flag:
    name: test_flag
    enabled: true
    path: /tmp/test-flags.json
    api_token: dummy
  register: second

- name: Check mode must not modify anything
  mycompany.platform.feature_flag:
    name: another_flag
    enabled: true
    path: /tmp/test-flags.json
    api_token: dummy
  check_mode: true
  register: dry

- ansible.builtin.stat:
    path: /tmp/test-flags.json
  register: st

- ansible.builtin.assert:
    that:
      - first is changed
      - second is not changed              # requirement 2
      - dry is changed                     # reports it WOULD change
      - "'another_flag' not in (lookup('file', '/tmp/test-flags.json') | from_json)"
    fail_msg: "feature_flag is not idempotent or check mode is broken"

The six decisions:

  1. type='path' rather than str — Ansible expands ~ and resolves the path for you.
  2. no_log=True on api_token — requirement 4. Automatic, for every caller.
  3. The state check comes first, before the check-mode guard, before any write. Requirement 2 depends on that order.
  4. The check-mode guard sits after the state check and before the write. Reverse those two and requirement 3 fails silently in the worst possible way.
  5. fail_json inside except, not a bare raise — requirement 5. A traceback reaching the control node produces "module failed to parse output" rather than a usable error.
  6. sort_keys=True in json.dump — otherwise the file's key order can vary between runs, the content differs, and a different module reading it might report spurious changes. The Module 04 idempotency lesson applied to output you generate.

Verify:

bash
echo '{"ANSIBLE_MODULE_ARGS": {"name":"f1","enabled":true,"path":"/tmp/f.json","api_token":"t"}}' > /tmp/a.json
python3 library/feature_flag.py /tmp/a.json      # changed: true
python3 library/feature_flag.py /tmp/a.json      # changed: false
echo '{"ANSIBLE_MODULE_ARGS": {"name":"f2","enabled":true,"path":"/tmp/f.json","api_token":"t","_ansible_check_mode":true}}' > /tmp/c.json
python3 library/feature_flag.py /tmp/c.json      # changed: true, file untouched
cat /tmp/f.json                                  # f2 must NOT be present

E3 · Command reference — everything from this module

Commands from Module 10. ⭐ marks genuinely daily-use.

Developing

bash
# ⭐ run a module directly - no inventory, no SSH, fastest loop
echo '{"ANSIBLE_MODULE_ARGS": {"name":"x","state":"present"}}' > /tmp/args.json
python3 library/mymodule.py /tmp/args.json

# ⭐ exercise the check-mode path
echo '{"ANSIBLE_MODULE_ARGS": {"name":"x","_ansible_check_mode":true}}' > /tmp/c.json
python3 library/mymodule.py /tmp/c.json

python3 -c "import json,sys; json.load(open('/tmp/args.json'))"    # validate the args JSON
ansible localhost -m mymodule -a "name=x" -M ./library             # ⭐ ad-hoc with a module path
ansible-playbook test.yml -M ./library -vvv                        # ⭐ explicit module path

Documenting and discovering

bash
ansible-doc mycompany.platform.tenant           # ⭐ render your DOCUMENTATION
ansible-doc -s mycompany.platform.tenant        # ⭐ paste-ready snippet
ansible-doc -t filter -l                        # ⭐ every filter available
ansible-doc -t lookup -l
ansible-doc -t callback -l
ansible-doc -l mycompany.platform               # ⭐ modules in your collection

Testing

bash
cd ~/collections/ansible_collections/mycompany/platform
ansible-test sanity --docker default                    # ⭐ docs, imports, pep8
ansible-test sanity --docker --test validate-modules    # ⭐ DOCUMENTATION vs argument_spec
ansible-test units --docker default
ansible-test integration --docker default
ansible-test integration tenant --docker                # one target
ansible-test sanity --docker --python 3.11              # a specific interpreter

Plugin locations

plain text
library/                    modules, beside a playbook
filter_plugins/             ⭐ filters, beside a playbook - no FQCN, no ansible-doc
lookup_plugins/
callback_plugins/
roles/<role>/library/       scoped to one role
collections/.../plugins/    ⭐ modules/ module_utils/ filter/ lookup/ action/ callback/

The module skeleton, condensed

python
from ansible.module_utils.basic import AnsibleModule

module = AnsibleModule(
    argument_spec=dict(
        name=dict(type='str', required=True),
        state=dict(type='str', default='present', choices=['present','absent']),
        token=dict(type='str', required=True, no_log=True),      # ⭐ secrets
    ),
    supports_check_mode=True,                                     # ⭐
    required_if=[('state','present',['size'])],
    mutually_exclusive=[['name','id']],
)

module.params            # ⭐ validated, type-cast
module.check_mode        # ⭐ bool
module.run_command([...], check_rc=True)
module.warn("...")
module.exit_json(changed=True, thing={...})                       # ⭐
module.fail_json(msg="...")                                       # ⭐ never raise
The three-step module body, worth writing from memory:
plain text
1. Inspect current state.  Matches desired?  -> exit_json(changed=False)
2. if module.check_mode:                     -> exit_json(changed=True)
3. Make the change                           -> exit_json(changed=True)

Steps 1 and 2 in that order are what make the module idempotent and check-mode safe. Everything else is detail.


E4 · Official documentation

LinkCovers
Developing modulesThe full walkthrough, from skeleton to first run
Module best practicesConventions, return values, error handling
Module documentation formatDOCUMENTATION, EXAMPLES, RETURN syntax
Developing pluginsFilter, lookup, callback, action, inventory plugins
AnsibleModule referenceargument_spec, run_command, exit_json, fail_json
Testing collections with ansible-testsanity, units, integration
Sanity testsEvery sanity test including validate-modules
Conventions and pitfallsWhat reviewers reject when a module is submitted upstream

E5 · Self-assessment

1. Before writing a module, what four things do you check?

Does a module already exist (search the index). Is it an HTTP API — then uri plus a role. Is it data transformation — then a filter plugin. Is it fetching data on the control node — then a lookup plugin.

Only after all four does a module become the right answer, and then mainly because you need idempotency and check-mode support.

2. State the three-step module body, in order.

Inspect current state and exit_json(changed=False) if it already matches. Then, if a change is needed, if module.check_mode: exit_json(changed=True). Only then make the change.

Reversing steps 2 and 3 means the module modifies production during a dry run.

3. What does no_log=True in an argument spec do?

Replaces that value with VALUE_SPECIFIED_IN_NO_LOG_PARAMETER in all output, verbose logs and the invocation echo — automatically, for every caller, without them needing no_log on their task. It is one of the concrete advantages of a module over command.

4. Why must a module never print()?

It communicates through exactly one JSON document on stdout. Anything else corrupts it and yields "module failed to parse output". Use module.warn(), module.fail_json(), module.exit_json().

5. Which plugin types run where?

Only modules run on the managed node. Filter, lookup, test, callback, inventory, action, connection and strategy plugins all run on the control node.

That is why lookup('file', ...) reads the control node's filesystem.

6. Why does template need an action plugin?

Because the Jinja2 template file and your variables live on the control node, and a module executes on the target. The action plugin renders locally, then hands the finished text to the copy module for transfer.

7. What must a lookup plugin return?

A list, always, even for a single term. And it runs on the control node once per host in a per-host task, so on a large fleet fetch once with run_once and delegate_to: localhost, then read from hostvars.

8. What does validate-modules check, and why does it matter?

That the DOCUMENTATION block matches the real argument_spec — every option documented, types agreeing, defaults consistent.

It catches the most common maintenance failure: a parameter added without documentation, so ansible-doc starts lying to the module's users.

9. Write the most valuable integration test for a module.

Run it, run it again, assert first is changed and second is not changed. That is idempotency turned into an executable check, and it catches the commonest real bug — acting unconditionally instead of inspecting state first.

Extend it with a check_mode: true run plus a stat/content assertion proving nothing was actually modified.

10. Where should a plugin live, and what do you lose in the alternatives?

In a collection under plugins/. Beside a playbook in library/ or filter_plugins/ works and is fine for a one-off, but you get no FQCN, no ansible-doc, no versioning, and two projects defining the same filter name will collide.


Next — Module 11 · Testing, Molecule & CI/CD.

You have written roles, collections and now modules. Module 11 covers proving they work: ansible-lint, yamllint, Molecule scenarios, idempotence testing, and wiring it all into a pipeline.

📚 Sources for the interview questions

Behaviour verified against the current developing modules guide and developing plugins guide.

Question selection cross-referenced against publicly published 2026 Ansible interview question sets:

Answers were rewritten and deepened rather than reproduced — published versions are usually correct but shallow, and the added operational detail is what differentiates a candidate in the room.

Spotted a mistake or want something added? Send me a note.