AnsibleLI · Custom Modules and Tool SelectionDeciding to write code
Exhaust the alternatives first
What you'll learn
- Work through the five alternatives to a custom module in order, and know what each costs
- Reimplement a typical custom module as three uri tasks
- State the maintenance burden a custom module creates, in terms a reviewer can weigh
- Name the two situations where writing a module genuinely is the right answer
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
Writing a custom module is one of the more satisfying things you can do with Ansible. It is also, most of the time, the wrong answer to the problem that prompted it — and the wrongness does not show up for about eighteen months.
This part covers how to write one properly, because sometimes you should. This lesson is about the decision that comes first, and it is the more consequential half.
What a custom module actually costs
The cost is not the writing. A competent Python programmer produces a working module in an afternoon, which is exactly why this decision gets made casually.
The cost is everything after the merge:
| Obligation | What it means in practice |
|---|---|
| Maintenance | Somebody owns this when the target API changes, when Python deprecates a library it uses, when ansible-core changes the module interface |
| Testing | It has no upstream test suite. Yours is the only one, and if you do not write it, there is none |
| Documentation | ansible-doc shows what you wrote. A colleague reading the play at 03:00 gets your DOCUMENTATION block or nothing |
| Debuggability | When it misbehaves, the engineer on call has to read Python they have never seen, inside a payload shipped to a remote host |
| Correctness | Idempotency, accurate changed, working check mode and no_log are now your responsibility. Lesson 3 is the list, and every item is a way to be wrong |
| Bus factor | Modules are usually written by one enthusiastic person. Estimate honestly how many colleagues could modify it next year |
None of that is an argument against ever writing one. It is the price tag, and the alternatives below are cheaper on every row.
The five alternatives, in order
Work down this list. Stop at the first one that fits.
1. An existing module in a collection
The most common reason for writing a module is not knowing one exists. Ansible ships thousands across the collections, and the search is worth doing properly rather than from memory.
# Every module available on this controller, with its short description.
ansible-doc -l | grep -i firewall
# Name and file path for each - reveals which collection it came from.
ansible-doc -F | grep -i firewall
# Read the full documentation for a candidate.
ansible-doc community.general.ufwSearch Galaxy too, and apply Part XXVII’s evaluation criteria before adopting: when it was last released, how many maintainers it has, whether it has tests. A poorly maintained third-party collection is a genuine reason to prefer your own code — but that is a judgement about a specific collection, not a reason to skip the search.
2. uri against the thing’s API
If the target has an HTTP API — and most modern infrastructure does —
uri is a full HTTP client with authentication, body serialisation,
status assertions and return-value registration. Most custom modules
written in the last decade are HTTP clients with a bad interface.
- name: Read the current configuration
ansible.builtin.uri:
url: 'https://api.example.com/v1/pools/{{ pool_name }}'
method: GET
headers:
Authorization: 'Bearer {{ api_token }}'
status_code: [200, 404]
register: pool_current
changed_when: false
- name: Apply the desired configuration
ansible.builtin.uri:
url: 'https://api.example.com/v1/pools/{{ pool_name }}'
method: PUT
headers:
Authorization: 'Bearer {{ api_token }}'
body_format: json
body:
algorithm: '{{ pool_algorithm }}'
timeout: '{{ pool_timeout }}'
status_code: [200, 201]
when: >-
pool_current.status == 404
or pool_current.json.algorithm != pool_algorithm
or pool_current.json.timeout != pool_timeout
register: pool_applied
- name: Confirm the service reports what we asked for
ansible.builtin.uri:
url: 'https://api.example.com/v1/pools/{{ pool_name }}'
method: GET
headers:
Authorization: 'Bearer {{ api_token }}'
status_code: 200
register: pool_after
changed_when: false
failed_when: pool_after.json.algorithm != pool_algorithmRead what that shape gives you for free. The when: is the idempotency
check, written in the play where a reviewer can see it. The changed_when: false on the reads is honest, because a GET changes nothing. The
failed_when on the final read is verification of outcome rather than of
task success, which is Part XXVI’s discipline. And api_token is a
variable, so no_log and vault handling work exactly as they do
everywhere else.
3. command with an honest changed_when and failed_when
When the thing has a CLI and no API, command plus the precision
keywords from Part XII gets you a task that reports accurately even
though the underlying tool knows nothing about Ansible.
- name: Read the current licence state
ansible.builtin.command:
cmd: /usr/local/bin/appctl licence show --format json
register: licence_state
changed_when: false
check_mode: false
- name: Apply the licence only if it differs
ansible.builtin.command:
cmd: /usr/local/bin/appctl licence set --key {{ licence_key | quote }}
when: (licence_state.stdout | from_json).key != licence_key
register: licence_set
changed_when: licence_set.rc == 0check_mode: false on the read is deliberate and often forgotten: it
lets the read run during a --check pass so the conditional on the
second task can be evaluated, which is what makes the dry run
informative rather than a pair of skips. Part XXV covers the trap in
full.
This is the alternative with the most caveats, and Part LII lesson 1 catalogues what goes wrong when it is applied without them. It is still better than a module for a one-off, because the failure is visible in the play rather than hidden in Python.
4. A filter or lookup plugin
If what you actually need is a transformation — parsing a vendor format, computing an address range, reshaping a data structure — that is not a module. A module executes on the target; a filter transforms data on the controller.
Filters are dramatically cheaper than modules: a plain Python function
with no state, no idempotency contract, no check-mode obligation and no
changed value to get right. Lesson 5 covers the choice properly.
5. A role
If the answer is “several tasks that always go together, parameterised”, that is a role, and it is the alternative teams reach for last despite it being right most often.
A role gives you defaults, argument validation via argument_specs.yml,
handlers, and a name — everything a module gives you except the illusion
of atomicity, and it is written in YAML the whole team can read.
When a module genuinely is the answer
Two situations, and they are narrower than they sound.
A genuine API with no collection, used repeatedly. Internal systems
qualify — an in-house provisioning API, a bespoke CMDB. The
discriminator is repeatedly: three uri tasks used once is fine
forever; the same three tasks copied into eleven roles is a module
waiting to happen.
Logic that must run on the target and report change honestly.
Something that must inspect remote state, decide, act, and return an
accurate changed — where the decision needs data that only exists on
the managed node and is too involved to express in a conditional. A
module runs there; a filter does not.
Knowledge check
Knowledge check · 5 questions
Q1. A team needs to configure an internal service that exposes a REST API. What should they try before writing a custom module?
Q2. Which obligations does merging a custom module create that a role built from uri tasks does not? Select all that apply.
Q3. A module returns its result by printing exactly one JSON object to stdout, so any stray output from the module or a library it imports breaks the run.
Q4. A team has written the same three uri tasks into eleven different roles. What does this indicate?
Q5. A well-maintained collection module does almost everything you need but lacks one option. Which response has the best long-term economics?
Passing score: 75%. Answers are checked in this browser.