Skip to main content
RunBook Academy

AnsibleVIII · Modules and the Module ModelThe module model

Choosing a module, and what you lose without one

Intermediate⏱ ~19 minansible-docansible-galaxy

What you'll learn

  • Search systematically for a purpose-built module before reaching for command
  • Name the five capabilities a shell task gives up, and why each matters operationally
  • Recognise argument validation as a safety property rather than a convenience
  • Decide honestly when a shell task is the correct 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.

“Use a module instead of shell” is usually delivered as style advice, which makes it easy to dismiss when you are in a hurry and the shell line already works.

It is not style advice. A shell task gives up five specific capabilities, each of which shows up as a concrete operational problem later. This lesson names all five, and gives the search procedure that finds the module you probably did not know existed.

The search procedure

Three steps, about forty seconds.

1. Search what is installed

Read-only / Safegrep the one-line summaries
$ ansible-doc -l | grep -i -E 'repositor|apt|package'
ansible.builtin.apt                    Manages apt-packages
ansible.builtin.apt_key                Add or remove an apt key
ansible.builtin.apt_repository         Add and remove APT repositories
ansible.builtin.deb822_repository      Add and remove deb822 formatted repo...
ansible.builtin.dpkg_selections        Dpkg package selection selections
ansible.builtin.package                Generic OS package manager
ansible.builtin.package_facts          Package information as facts
ansible.builtin.yum_repository         Add or remove YUM repositories

Illustrative output

Search by the noun rather than the verb. “repository”, “service”, “user”, “mount”, “firewall” — module names are almost always nouns, and the summaries are written that way.

ansible-core ships 71 modules and no more. If the noun you want is not there, it is in a collection.

2. Check the collections

The obvious candidates for infrastructure work are community.general (very large, uneven), ansible.posix (mounts, sysctl, SELinux, ACLs), community.crypto, community.mysql, community.postgresql, and whatever your vendor publishes.

Read-only / Safewhat is installed here
$ ansible-galaxy collection list

Adding a collection is a real dependency decision — trust, pinning, supply chain — and this course gives it a part of its own. The point here is only that “there is no module for this” usually means “there is no module in ansible-core for this”, which is a much weaker statement.

3. Only now consider command

If steps 1 and 2 come up empty, ansible.builtin.command is a legitimate answer, and the next part of this course is about doing it well. Choose command over shell unless you specifically need shell metacharacters.

The five losses

Loss 1: accurate change reporting

# What was written
- name: Install nginx
  ansible.builtin.shell: apt-get install -y nginx

This reports changed: true on every run, including every run where nginx was already installed. shell cannot know.

# What it should be
- name: Install nginx
  ansible.builtin.package:
    name: nginx
    state: present

This reports changed: true on the run that installs it and changed: false thereafter.

The operational consequence is not tidiness. Handlers fire on changed, so a shell install notifies every handler downstream of it on every run. Conditionals test changed, so when: install is changed becomes permanently true. Fleet reports count changed, so a nightly run against 300 hosts reports 300 changes every night and stops being read.

Loss 2: check-mode support

--check asks each module to predict. shell and command support check mode only through creates and removes; without one of those the task is skipped:

Read-only / Safea check run that evaluated nothing
$ ansible localhost -m ansible.builtin.command -a "/usr/local/bin/migrate.sh" --check
localhost | SKIPPED

raw has no check-mode support at all. package and service delegate the question to whatever they dispatch to.

A play that is half purpose-built modules and half shell produces a green --check run in which half the tasks were never evaluated. This is important enough to have its own lesson next.

Loss 3: diff output

--diff shows the content that would change. Eighteen ansible-core modules support diff_mode: full, and they are essentially the ones that manage file content: copy, template, lineinfile, blockinfile, replace, assemble, cron, git, plus the package and repository modules.

shell supports diff_mode: none. A task that edits a config file with sed -i can never show you what it changed, before or after.

That matters most in review. A change request containing --check --diff output is reviewable by someone who was not there. A change request containing “runs a sed command” is not.

Loss 4: argument validation

This is the loss people underrate, because it looks like an error-message improvement and is actually a safety property.

A module declares an argument specification: which options exist, their types, which are required, which are mutually exclusive. Ansible validates against it before the module does any work.

Read-only / Safea typo in an option name
$ ansible localhost -m ansible.builtin.stat -a "path=/etc/hostname folow=true"
localhost | FAILED! => {
  "changed": false,
  "msg": "Unsupported parameters for (ansible.builtin.stat) module: folow. Supported parameters include: checksum_algorithm, follow, get_attributes, get_checksum, get_mime, get_selinux_context, path."
}

Illustrative output

Read-only / Safea value of the wrong type
$ ansible localhost -m ansible.builtin.stat -a '{"path": "/etc/hostname", "get_checksum": "banana"}'
localhost | FAILED! => {
  "changed": false,
  "msg": "argument 'get_checksum' is of type str and we were unable to convert to bool: The value 'banana' is not a valid boolean. Valid booleans include: 0, 1, 'true', 'y', 'yes', 'on', '0', 'n', 't', 'no', '1', 'off', 'false', 'f'"
}
Read-only / Safea missing required option
$ ansible localhost -m ansible.builtin.stat -a "follow=true"
localhost | FAILED! => {
  "changed": false,
  "msg": "missing required arguments: path"
}

Now the shell equivalent. shell: rm -rf {{ target_dir }}/cache with a target_dir that is undefined and renders empty does not fail validation, because there is no specification to validate against. It constructs rm -rf /cache and executes it.

That is the difference in one sentence: a module rejects a malformed instruction; a shell line executes a malformed instruction. The validation is not a nicer error message. It is the step that stops a typo from becoming an outage.

Loss 5: platform abstraction

- name: Works on Debian only
  ansible.builtin.shell: apt-get install -y chrony

- name: Works wherever the fleet runs
  ansible.builtin.package:
    name: chrony
    state: present

package detects the target’s package manager and dispatches. The same task works on Debian, RHEL, SUSE and Alpine.

The honest qualification: package abstracts the manager, not the package name. libyaml-dev on Debian is libyaml-devel on RHEL, and the module documentation says explicitly that it will not translate them. Where the names differ you still need a variable per platform — but you need one variable, not two whole task branches with a when: each.

When a shell task is genuinely correct

Being honest about this is what makes the rest credible.

  • A vendor tool with no module. A proprietary CLI that manages a licence server. Nobody has written a module and you are not going to.
  • A one-shot migration. Runs once, on a date, and is deleted afterwards. Idempotency is not a requirement because it will never run again.
  • A read-only probe feeding a when:. A short command whose output decides whether a later task runs. Paired with changed_when: false, this is a legitimate and common pattern.
  • Bootstrapping. raw before Python exists on the target — nothing else can run at all.

What is not on that list: “the module has too many options”, “the shell command is shorter”, “I already know the shell syntax”. Those are reasons to spend forty seconds with ansible-doc.

Knowledge check

Knowledge check · 4 questions

  1. Q1. Which loss best explains why replacing a shell install with ansible.builtin.package changes the behaviour of a handler downstream of it?

  2. Q2. Argument validation is a safety property rather than a convenience, because a module rejects a malformed instruction while a shell line executes one.

  3. Q3. Which of these are honest reasons to use a shell or command task? Select all that apply.

  4. Q4. What exactly does ansible.builtin.package abstract, and what does it not?

Passing score: 75%. Answers are checked in this browser.