Chapter 3: Modules & Ad-hoc Commands
Topic 16

command and shell vs Real Modules

ModulesEscape Hatch

The command and shell modules run an arbitrary command line on the managed node, and they are the escape hatch you reach for least, not most. They are not idempotent — Ansible cannot know what an arbitrary command does, so by default they report changed on every single run, fire handlers needlessly, and break the "second run is all ok" guarantee the whole model rests on.

The rule is simple: reach for a real module first, and use command/shell only when no module exists for the job, guarded so they stop lying about change. This topic is the discipline that keeps the honest changed flag from the last topic actually honest.

Real module vs command/shell
Real module
Checks current state, acts only when needed, and reports changed honestly — idempotent by design.
command / shell
Just executes; Ansible cannot know the effect, so it reports changed every run unless guarded with creates or changed_when.

Why They Always Report changed

A real module inspects state and compares; command and shell just execute. Since Ansible has no idea whether running /opt/larkspur/migrate.sh changed anything, it conservatively reports changed every run. That is honest ignorance, not a bug — the module genuinely cannot tell, so it refuses to claim otherwise.

The cost lands downstream. Every run that reports changed fires any handler it notifies and makes the recap look like the host never settles. A play built on raw commands never goes all-ok on its second run, which means you lose the one signal that tells you a host is converged.

command vs shell

command runs a binary directly with no shell, so pipes, redirects, &&, and environment expansion do not work — and injection is harder, because there is no shell to interpret metacharacters. shell runs through /bin/sh and accepts all of that, at the cost of shell-quoting hazards and a wider injection surface.

Prefer command unless you specifically need shell features. If you only reach for shell to chain two commands with &&, splitting them into two command tasks is usually cleaner and drops the quoting risk entirely.

The creates and removes Guards

creates=/opt/larkspur/.migrated tells the module to skip running if that path already exists, and removes to skip if it is absent. This gives a crude idempotency: the migration runs once, drops its marker, then reports ok forever after. It is how you make a one-shot command safe to leave in a play that runs repeatedly.

The guard below runs a database migration exactly once. The first run executes the script and the creates file appears; every run after that sees the marker and skips, reporting ok instead of re-applying the migration or failing on a second attempt.

A one-shot command made safe with creates and changed_when
- name: Run the Larkspur schema migration once
  ansible.builtin.command: /opt/larkspur/migrate.sh
  args:
    creates: /opt/larkspur/.migrated   # skip if the marker already exists

- name: Read the current schema version (read-only)
  ansible.builtin.command: /opt/larkspur/schema-version
  register: schema
  changed_when: false            # never claim a change for a read

The changed_when and failed_when Guards

changed_when: false on a read-only command stops it reporting false changes — the second task above reads a version and rightly claims nothing changed. For richer cases, changed_when and failed_when take a Jinja2 expression over the registered result, letting you define "changed" and "failed" from the command's rc or stdout.

These guards restore the honest reporting the module cannot infer itself. You are supplying the knowledge Ansible lacks: this command changed something only when its output says so, or failed only on a specific return code. With them, a raw command behaves like the idempotent tasks around it instead of churning every run.

Reach for a Real Module First

Most reasons to reach for a raw command dissolve on inspection. command -a "useradd larkspur" should be the user module; shell -a "systemctl restart nginx" should be service; command -a "apt-get install nginx" should be apt. The real module is idempotent, reports honestly, and is safe to re-run, which the raw command is not.

Interpolating an unvalidated variable into a shell string also opens a command-injection path that a real module's structured arguments close by construction. The module takes name=nginx as data, not as text spliced into a command line, so there is no metacharacter for an attacker to exploit.

The Legitimate Cases

Some jobs are real: running a bespoke app script, a one-off migration, a vendor CLI with no module behind it. These are not failures of discipline — no module exists, so the escape hatch is the honest tool. The discipline is to guard them.

Wrap every necessary raw command in creates/removes for one-shot work and changed_when/failed_when for honest reporting, so it behaves like the idempotent tasks around it rather than churning every run. A guarded command is a good citizen; an unguarded one is the thing that makes your recap lie.

command vs shell vs a real module

command runs a binary with no shell, the safest of the raw two — no pipes, no expansion, a smaller injection surface. shell runs through /bin/sh for pipes and redirects, with quoting and injection risk you take on deliberately.

A real module (apt, service, user) is idempotent and reports honest change, which neither raw module can do alone. Always prefer the real module; choose command over shell when you must drop to raw; reach for shell only when you actually need shell features.

Common Mistakes
  • Using shell -a "systemctl restart gunicorn" instead of the service module, so the task reports changed every run, fires the restart gunicorn handler needlessly, and bounces the app on every play even when nothing changed.
  • Running a one-shot migration with command and no creates guard, so it re-runs on every play and either double-applies or fails the second time, because nothing tells it the work is already done.
  • Reaching for shell to get a pipe or && when command plus splitting into two tasks would do, taking on shell-quoting and injection risk for convenience.
  • Leaving a read-only command -a "psql -c 'SELECT ...'" reporting changed every run because changed_when: false was never set, making the recap claim a change that did not happen.
  • Interpolating an unvalidated variable into a shell command string, opening a command-injection path that a real module's structured arguments would have closed.
Best Practices
  • Default to a real module — apt, service, file, user — and treat command/shell as a last resort for jobs no module covers.
  • Prefer command over shell, dropping to shell only when you genuinely need a pipe, redirect, or shell expansion, to limit the quoting and injection surface.
  • Guard every necessary command/shell with creates/removes for one-shot work and changed_when/failed_when for honest reporting, so they behave idempotently like the tasks around them.
  • Set changed_when: false on any purely read-only command so it never claims a change and never falsely triggers a handler.
  • Pass data to real modules as structured arguments rather than splicing variables into a shell string, closing the injection path by construction.
Comparable tools Bash provisioning script exactly what command/shell wrap Puppet exec · Chef execute the same guarded-escape-hatch idea Terraform remote-exec / local-exec the cross-tool analog, and the same anti-pattern

Knowledge Check

Why are command and shell not idempotent, and what do they report by default?

  • Ansible cannot know what an arbitrary command does, so they report changed on every run as honest ignorance
  • They report ok on every single run, because Ansible just assumes that any raw command is always safe to repeat freely
  • They are genuinely idempotent, but only when the task is actually run under --become escalation
  • They report failed on the very second run, purely to prevent any accidental re-application

What is the difference between command and shell, and when is each appropriate?

  • command runs a binary with no shell and a smaller injection surface; shell uses /bin/sh for pipes/redirects — prefer command, use shell only when needed
  • shell is fully idempotent while command is not, so reaching for shell first is always the safer and more predictable default everywhere
  • command needs --become escalation just to run at all while shell never does, so shell is the one you should clearly always prefer for any unprivileged work
  • They are functionally identical to each other in every single respect; shell is really just the newer renamed-and-rebranded version of command

What do creates and changed_when restore to a raw command?

  • creates gives one-shot idempotency by skipping if a path exists; changed_when restores the honest change reporting the module cannot infer
  • Both simply make the raw command run noticeably faster by quietly caching its standard output in between successive runs
  • creates escalates the task's privilege level straight to root and changed_when automatically retries the whole command again on any failure
  • They quietly convert the raw command into a genuine, fully state-aware and idempotent real module at run time on the target host itself

Why does a real module beat a raw command even when the raw command would work?

  • The module is idempotent, reports change honestly, and takes arguments as structured data, closing the injection path a shell string leaves open
  • The module always runs noticeably faster because it gets to skip opening the SSH connection to the host entirely
  • Raw commands simply cannot run under --become escalation at all, so any privileged work strictly requires a real module
  • Modules are the only kind of task whose result you can actually register into a variable and then conditionally branch on afterward later in the play

You got correct