Skip to main content
RunBook Academy

AnsibleLI · Custom Modules and Tool SelectionDeciding to write code

Exhaust the alternatives first

Advanced⏱ ~28 minbash

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

Not yet marked complete on this device.

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:

ObligationWhat it means in practice
MaintenanceSomebody owns this when the target API changes, when Python deprecates a library it uses, when ansible-core changes the module interface
TestingIt has no upstream test suite. Yours is the only one, and if you do not write it, there is none
Documentationansible-doc shows what you wrote. A colleague reading the play at 03:00 gets your DOCUMENTATION block or nothing
DebuggabilityWhen it misbehaves, the engineer on call has to read Python they have never seen, inside a payload shipped to a remote host
CorrectnessIdempotency, 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 factorModules 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.

Read-only / Safesearch the modules you actually have installed
# 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.ufw

Search 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.

Configuration changea 200-line custom module, as three uri tasks
- 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_algorithm

Read 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.

Configuration changea CLI wrapped honestly
- 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 == 0

check_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

  1. Q1. A team needs to configure an internal service that exposes a REST API. What should they try before writing a custom module?

  2. Q2. Which obligations does merging a custom module create that a role built from uri tasks does not? Select all that apply.

  3. 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.

  4. Q4. A team has written the same three uri tasks into eleven different roles. What does this indicate?

  5. 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.