AnsibleIX · command, shell and rawcommand, shell and raw
command: no shell, and why that is the safe one
What you'll learn
- Explain how ansible.builtin.command turns a free-form string into an argument vector
- Predict which shell features do and do not work under command
- Use argv, chdir, stdin and expand_argument_vars correctly
- Describe exactly what command does under --check, and why
- Explain why a command task reports changed on every run
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
ansible.builtin.command runs a program on the managed node. It does
not run a shell. That single sentence is responsible for every
surprising thing about the module, and for the reason this course
prefers it over ansible.builtin.shell by default.
Most people meet command by writing a task that works, then writing a
second task that mysteriously does not:
- name: Reload the application config
ansible.builtin.command: /usr/sbin/appctl reload
- name: Archive yesterday's logs
ansible.builtin.command: cat /var/log/app/*.log > /var/log/app/archive.txtThe second task does not write an archive. It does not fail either — it
hands cat five literal arguments, one of which is the string >, and
cat reports that it cannot open a file called >. The playbook shows
you a failed task with an error message about a filename you never
typed.
What the module does with your string
The free-form string is not passed to a shell. It is split into an
argument list by Python’s shlex, and that list is handed to the
operating system’s exec family directly.
shlex understands quoting, and nothing else. It knows that
echo "hello world" is two arguments. It does not know what *, >,
|, ;, &, &&, backticks or $(...) mean, because those are shell
syntax and there is no shell in this path.
$ ansible-playbook -i localhost, expand.ymlTASK [command, glob and pipe are literal] ***************************************
ok: [localhost]
TASK [Results] *****************************************************************
ok: [localhost] => {
"msg": [
"metacharacters : * | > cmd=['/bin/echo', '*', '|', '>']"
]
}That is the whole security property. A string that reaches command
cannot grow a second command, because there is nothing on the far end
that knows how to parse one. The limitation and the safety are the same
mechanism seen from two sides.
Four ways to write the same task
| Form | When to use it |
|---|---|
Free form: ansible.builtin.command: /usr/bin/foo -x | Short, unambiguous invocations. |
cmd: as a mapping key | Same as free form, but readable next to other options. |
argv: as a list | Any argument that contains a space, a quote, or a value from a variable. |
Free form plus an args: block | Legacy style; cmd: reads better today. |
- name: Free form
ansible.builtin.command: /usr/sbin/appctl reload
- name: cmd as an option
ansible.builtin.command:
cmd: /usr/sbin/appctl reload
chdir: /opt/app
- name: argv, one element per argument
ansible.builtin.command:
argv:
- /usr/sbin/appctl
- --config
- /etc/app/main config.yml
- name: Free form with an args block
ansible.builtin.command: /usr/sbin/appctl reload
args:
chdir: /opt/appThe third form is the one worth internalising. /etc/app/main config.yml
contains a space; in the free-form string it would need quoting, and the
moment that path comes from a variable the quoting becomes your problem
in a way the next two lessons are entirely about. As a list element it
is simply one argument.
The options that matter
$ ansible-doc ansible.builtin.commandOPTIONS (red indicates it is required):
argv Passes the command as a list rather than a string.
chdir Change into this directory before running the command.
cmd The command to run.
creates A filename or (since 2.0) glob pattern. If a matching file
already exists, this step will not be run.
expand_argument_vars Expands the arguments that are variables ...
removes A filename or (since 2.0) glob pattern. If a matching file
exists, this step will be run.
stdin Set the stdin of the command directly to the specified value.
stdin_add_newline If set to true, append a newline to stdin data.
strip_empty_ends Strip empty lines from the end of stdout/stderr.chdir is a real chdir(2) before exec, not a cd prefix on a shell
line. stdin writes a string to the process’s standard input, which is
how you feed a value to a program that insists on reading one without
putting it on a command line where ps can see it.
expand_argument_vars
Since ansible-core 2.16 the module expands environment variables in arguments itself, in Python, before exec. This is the one place where something that looks like shell behaviour happens without a shell — and it behaves differently from a shell in a way worth knowing.
$ ansible-playbook -i localhost, expand.ymlok: [localhost] => {
"msg": [
"expand default : /home/deploy $NOT_SET_ANYWHERE cmd=['/bin/echo', '$HOME', '$NOT_SET_ANYWHERE']",
"expand disabled: $HOME cmd=['/bin/echo', '$HOME']"
]
}Illustrative output
Two things in that output pay for themselves later. An unmatched
variable is left as the literal text, where a shell would have
substituted the empty string — so a typo in an environment variable name
produces a nonsense argument rather than a silently missing one. And the
cmd field in the result records the arguments before expansion,
which is what you will be reading in a post-incident -vvv transcript.
Set expand_argument_vars: false when an argument legitimately contains
a dollar sign — a crypt-format password hash, an AWK program, a regular
expression with $ anchors.
command under --check
This is where most people are wrong about command, and it matters
because check mode is the pre-flight tool the rest of this course leans
on.
ansible-doc states the attribute plainly: check-mode support is
partial, and the detail says the support consists entirely of
creates and removes.
$ ansible-playbook -i localhost, --check checkmode.ymlTASK [command with no guard] ***************************************************
skipping: [localhost]
TASK [command guarded by creates] **********************************************
ok: [localhost]
TASK [shell with no guard] *****************************************************
skipping: [localhost]
TASK [raw] *********************************************************************
skipping: [localhost]
PLAY RECAP *********************************************************************
localhost : ok=1 changed=0 unreachable=0 failed=0 skipped=3An unguarded command task in check mode is skipped. It does not
run, and it does not predict. That has a consequence people meet at the
worst possible moment: a play whose real work happens in command tasks
will pass --check cleanly while proving nothing at all, and every task
that depends on a variable registered from one of those skipped tasks
will then fail or behave differently.
Why every command task reports changed
A command task that ran shows changed. Always. A command task that
did nothing but read a file still shows changed.
$ ansible-playbook -i localhost, changed.ymlTASK [Read the current user, no changed_when] **********************************
changed: [localhost]
TASK [Read the current user, changed_when derived from output] *****************
ok: [localhost]
TASK [Read the current user, guarded by creates] *******************************
ok: [localhost]
PLAY RECAP *********************************************************************
localhost : ok=3 changed=1 unreachable=0 failed=0 skipped=0The consequences are not cosmetic. A changed result fires handlers, so
a read-only probe written as a command task will restart a service
every single run. It also destroys the one number an operator reads
first in a recap, and the one an auditor reads first in a log. Lesson 5
of this part covers the three correct fixes, in order of preference.
Things that are no longer true
Two pieces of command folklore are still widely repeated:
warn:does not exist. It was the parameter that suppressed the old “consider using the apt module” warning, and it was removed. Pass it today and the task fails on an unsupported parameter.executable:is accepted but ignored, with a warning: “As of Ansible 2.4, the parameter ‘executable’ is no longer supported with the ‘command’ module.” If you need to choose an interpreter, you are asking forshell,script, or an explicit interpreter asargv[0].
Blast radius
command runs on every host the play targets, in parallel, up to
forks. There is no dry run for it worth the name, and no rollback for
whatever it did.
Before running a play whose work is in command tasks:
# Which hosts will this actually touch?
ansible-playbook -i inventories/prod site.yml --limit webservers --list-hosts
# Which tasks, including those pulled in by roles and includes?
ansible-playbook -i inventories/prod site.yml --limit webservers --list-tasksBoth are read-only, both take a second, and both answer a question the run itself will not.
Knowledge check
Knowledge check · 4 questions
Q1. A task reads ansible.builtin.command: tar -czf /backup/app.tgz /srv/app/* — what reaches tar?
Q2. Which of these are genuine reasons to prefer the argv list form over the free-form string? Select all that apply.
Q3. Running ansible-playbook --check on a play whose work is done in command tasks validates that those tasks will succeed.
Q4. Why does a command task that only reads a value still report changed?
Passing score: 75%. Answers are checked in this browser.