command and shell vs Real Modules
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.
command/shellchanged honestly — idempotent by design.command / shellchanged 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.
- 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 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.
- Using
shell -a "systemctl restart gunicorn"instead of theservicemodule, so the task reportschangedevery run, fires therestart gunicornhandler needlessly, and bounces the app on every play even when nothing changed. - Running a one-shot migration with
commandand nocreatesguard, 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
shellto get a pipe or&&whencommandplus splitting into two tasks would do, taking on shell-quoting and injection risk for convenience. - Leaving a read-only
command -a "psql -c 'SELECT ...'"reportingchangedevery run becausechanged_when: falsewas never set, making the recap claim a change that did not happen. - Interpolating an unvalidated variable into a
shellcommand string, opening a command-injection path that a real module's structured arguments would have closed.
- Default to a real module —
apt,service,file,user— and treatcommand/shellas a last resort for jobs no module covers. - Prefer
commandovershell, dropping toshellonly when you genuinely need a pipe, redirect, or shell expansion, to limit the quoting and injection surface. - Guard every necessary
command/shellwithcreates/removesfor one-shot work andchanged_when/failed_whenfor honest reporting, so they behave idempotently like the tasks around them. - Set
changed_when: falseon 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
shellstring, closing the injection path by construction.
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
changedon every run as honest ignorance - They report
okon 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
--becomeescalation - They report
failedon the very second run, purely to prevent any accidental re-application
What is the difference between command and shell, and when is each appropriate?
commandruns a binary with no shell and a smaller injection surface;shelluses/bin/shfor pipes/redirects — prefercommand, useshellonly when neededshellis fully idempotent whilecommandis not, so reaching forshellfirst is always the safer and more predictable default everywherecommandneeds--becomeescalation just to run at all whileshellnever does, soshellis the one you should clearly always prefer for any unprivileged work- They are functionally identical to each other in every single respect;
shellis really just the newer renamed-and-rebranded version ofcommand
What do creates and changed_when restore to a raw command?
createsgives one-shot idempotency by skipping if a path exists;changed_whenrestores 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
createsescalates the task's privilege level straight to root andchanged_whenautomatically 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
--becomeescalation 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