AnsibleVIII · Modules and the Module ModelThe module model
Choosing a module, and what you lose without one
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
“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
$ 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 repositoriesIllustrative 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.
$ ansible-galaxy collection listAdding 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:
$ ansible localhost -m ansible.builtin.command -a "/usr/local/bin/migrate.sh" --checklocalhost | SKIPPEDraw 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.
$ 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
$ 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'"
}$ 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 withchanged_when: false, this is a legitimate and common pattern. - Bootstrapping.
rawbefore 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
Q1. Which loss best explains why replacing a shell install with ansible.builtin.package changes the behaviour of a handler downstream of it?
Q2. Argument validation is a safety property rather than a convenience, because a module rejects a malformed instruction while a shell line executes one.
Q3. Which of these are honest reasons to use a shell or command task? Select all that apply.
Q4. What exactly does ansible.builtin.package abstract, and what does it not?
Passing score: 75%. Answers are checked in this browser.