ArgumentSpec, Check Mode, Idempotency, and Diff¶
Build a Custom Module already demonstrates each of these in a working module. This page goes one level deeper on each piece, using one realistic module as the running example.
What You'll Learn¶
- The
argument_specfeatures that remove hand-written validation code - How
no_logprotects secret parameters, and what exactly it redacts - How to support check mode correctly — branching, not pretending
- Idempotency patterns beyond "compare two strings"
- How to return diffs that
--diffcan display - How to share code across modules with
module_utils, and document return values
The Running Example¶
A module that manages a key in an internal feature-flag service:
- name: Enable the new checkout flow for 10% of traffic
acme.platform.feature_flag:
name: new_checkout
state: present
rollout_percent: 10
environments: [staging, production]
api:
url: https://flags.internal.example.com
token: "{{ vault_flags_token }}"
argument_spec in Depth¶
argument_spec = dict(
name=dict(type="str", required=True, aliases=["flag"]),
state=dict(type="str", default="present", choices=["present", "absent"]),
rollout_percent=dict(type="int", default=100),
environments=dict(type="list", elements="str", default=["production"]),
owner=dict(type="str"),
api=dict(
type="dict",
required=True,
options=dict(
url=dict(type="str", required=True),
token=dict(type="str", required=True, no_log=True),
timeout=dict(type="int", default=10),
),
),
)
module = AnsibleModule(
argument_spec=argument_spec,
supports_check_mode=True,
required_if=[("state", "present", ["rollout_percent"])],
mutually_exclusive=[],
)
| Feature | What it gives you for free |
|---|---|
type |
Conversion and validation ("10" → 10; "ten" fails with a clear message) |
required / default |
Missing-argument errors and defaults, with no if statements |
choices |
Rejects values outside a fixed set |
aliases |
Alternative parameter names for compatibility |
elements |
Type of each item in a list |
options |
A nested spec for dict parameters, validated recursively |
no_log |
Marks a value as secret (see below) |
mutually_exclusive |
Parameters that can't be set together |
required_together |
Parameters that must be set together |
required_one_of |
At least one of a group must be set |
required_if |
(param, value, [required params]) — conditional requirements |
required_by |
{"a": ["b"]} — if a is set, b must be too |
Validation happens inside AnsibleModule(...), before your code runs. If it fails, the module exits with a failure message automatically.
Value-level rules that the spec can't express still go in code:
if not 0 <= module.params["rollout_percent"] <= 100:
module.fail_json(msg="rollout_percent must be between 0 and 100")
no_log: What It Redacts¶
Setting no_log=True on a parameter does two things:
- In the task's reported
invocation, the parameter's value is replaced withVALUE_SPECIFIED_IN_NO_LOG_PARAMETER. - Ansible masks occurrences of that exact value if it appears in other returned strings, such as an error message that echoes the request.
It does not stop your module from writing the secret to a file, a remote log, or an exception you build by hand. And it's separate from the task-level no_log: true, which hides the whole task result. For a secret parameter in a custom module, put no_log=True in the argument_spec so every caller is protected, even the ones who forget the task keyword. ansible-test sanity warns about parameters whose names look secret (token, password) without no_log.
Check Mode Internals¶
supports_check_mode=True is a promise, not a feature. Ansible doesn't simulate anything; it sets module.check_mode = True and trusts you to not change the system.
def main():
module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True)
client = FlagClient(module.params["api"])
current = client.get(module.params["name"]) # read: always safe
desired = build_desired(module.params)
changed = needs_change(current, desired, module.params["state"])
result = dict(changed=changed, flag=desired if module.params["state"] == "present" else None)
if module._diff:
result["diff"] = dict(before=current or {}, after=desired if module.params["state"] == "present" else {})
if changed and not module.check_mode: # write: only for real
if module.params["state"] == "present":
client.put(desired)
else:
client.delete(module.params["name"])
module.exit_json(**result)
The pattern is always the same: read, compute, report, and write only if not in check mode. The changed value must be identical in both modes — that's what makes --check a trustworthy preview.
If a module genuinely can't predict the outcome without acting, set supports_check_mode=False. Ansible then skips it in check mode and reports it as skipped, which is honest.
Idempotency Patterns¶
Normalize before comparing¶
APIs return extra fields, different key orders, and defaults you didn't send. Compare only what the user controls:
MANAGED_KEYS = ("rollout_percent", "environments", "owner")
def needs_change(current, desired, state):
if state == "absent":
return current is not None
if current is None:
return True
return any(
normalize(current.get(k)) != normalize(desired.get(k))
for k in MANAGED_KEYS
if desired.get(k) is not None # unset parameters mean "don't manage"
)
def normalize(value):
return sorted(value) if isinstance(value, list) else value
Sorting the list means [production, staging] and [staging, production] don't flap between runs.
Hash large content¶
For large files or blobs, compare checksums rather than content:
current_hash = module.digest_from_file(path, "sha256") if os.path.exists(path) else None
desired_hash = hashlib.sha256(content.encode()).hexdigest()
changed = current_hash != desired_hash
Write atomically¶
When writing files, write to a temporary path and module.atomic_move(tmp, dest), so a crash never leaves a half-written file.
The check-then-act race¶
"Read current state, then write" has a gap. On a busy, concurrently modified system, the state can change in between. Configuration management rarely hits this in practice, but when an API supports it, use conditional writes (an ETag or version field) so the write fails instead of silently overwriting someone else's change.
Diff Mode¶
--diff shows what changed. A module opts in by returning a diff key when module._diff is true:
result["diff"] = dict(
before_header=f"flag {name} (current)",
after_header=f"flag {name} (desired)",
before=yaml_dump(current or {}),
after=yaml_dump(desired),
)
before and after can be strings (shown as a unified diff) or dicts. Strings of serialized YAML usually read best. Run with --check --diff to preview without writing:
--- flag new_checkout (current)
+++ flag new_checkout (desired)
@@ -1,4 +1,4 @@
environments:
- staging
-rollout_percent: 0
+rollout_percent: 10
Never put a no_log value into a diff.
module_utils: Shared Code¶
Five modules talking to the same API shouldn't each carry their own HTTP client.
acme/platform/
└── plugins/
├── module_utils/
│ └── flags_client.py
└── modules/
├── feature_flag.py
└── feature_flag_info.py
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.acme.platform.plugins.module_utils.flags_client import FlagClient
When the module runs, Ansible bundles every imported module_utils file with it and ships them to the managed node together, so the shared code doesn't need to be installed there. Outside a collection, a module_utils/ directory next to the playbook is imported as ansible.module_utils.<name>.
Documenting Return Values¶
ansible-doc renders RETURN for users, and sanity tests check it's valid YAML:
RETURN = r"""
flag:
description: The flag as it exists after the task (null when state=absent).
returned: success
type: dict
sample:
name: new_checkout
rollout_percent: 10
environments: [staging, production]
contains:
rollout_percent:
description: Percentage of traffic that sees the flag.
type: int
returned: when state=present
"""
Document everything you return, including the returned: condition, so consumers know what they can register and rely on.
Common Mistakes¶
- Using
no_logonly on the task level and forgetting it belongs on theargument_specparameter itself for a custom module handling secrets. - Declaring
supports_check_mode=Trueand then writing anyway, making--checklie. - Computing
changeddifferently in check mode and normal mode. - Comparing raw API responses, so list ordering or server-added defaults report
changedon every run. - A "check-then-act" idempotency check that's actually a race — state can change between the check and the write on a genuinely concurrent system, which is worth calling out even though most Ansible use cases (config management, not high-frequency systems) rarely hit it in practice.
- Copy-pasting a client class into every module instead of using
module_utils.
Interview Questions¶
- What does
no_log=Trueon anargument_specparameter actually redact, and where? - How would you share validation logic across five related custom modules without duplicating it in each one?
- What does a module have to do to support check mode honestly?
- A custom module reports
changedon every run even though nothing changes. What would you look at first? - What's the difference between
required_ifandrequired_together?
Next¶
Continue to Build a Collection From Zero.