command, shell, and raw all run external programs on managed hosts, but they reach the host through different paths. Picking the wrong one leads to pipe failures, surprise CHANGED results, or tasks that only work when Python is already installed.
This guide compares the three modules, walks through argv, chdir, creates, removes, and register, and shows when dedicated modules replace ad hoc shell. You need one managed Linux host where SSH and become already work—any lab inventory is fine. Read Ansible modules and ansible-doc and ad hoc commands first.
Tested on: Rocky Linux 10.2 (Red Quartz); kernel 6.12.0-211.16.1.el10_2.0.1.x86_64; ansible-core 2.16.16.
~/ansible-project, inventory group lab, and playbooks in playbooks/. Use your own host names and paths if yours differ.
Use command for normal executables, shell only when you need shell syntax such as pipes or redirects, and raw only when Python is missing or the target cannot run normal Ansible modules.
What are command, shell and raw Modules in Ansible?
All three live in ansible.builtin and execute programs on managed nodes:
| Module | How it runs | Needs Python on target |
|---|---|---|
command |
Calls the executable directly—no shell | Yes |
shell |
Runs a string through /bin/sh (or executable) |
Yes |
raw |
Sends the string over SSH without the module subsystem | No |
command and shell are the everyday choice on normal Linux hosts. raw is the bootstrap and network-device escape hatch—see Bootstrap Python on managed nodes.
The default ad hoc module is command, so ansible lab -a "uptime" is the same as -m command.
command vs shell vs raw: Quick Comparison
command |
shell |
raw |
|
|---|---|---|---|
Shell features: pipes, redirects, && |
No | Yes | Yes, through configured remote shell |
| Python required on target | Yes | Yes | No |
| Module subsystem | Yes | Yes | No |
| Structured return values | Yes | Yes | Basic stdout/stderr/rc |
| Check mode support | Partial with creates/removes |
Partial with creates/removes |
No |
| Handler notify | Yes | Yes | No |
| Best use case | Plain executable with arguments | Real shell syntax | Bootstrap or no-Python devices |
| Default choice | Usually best | Use only when needed | Avoid for routine Linux admin |
When to Use the command Module
Reach for command when you run a single program with simple arguments:
- One executable with fixed arguments (
uptime,systemctl is-active httpd,test -f /path) - No pipes, redirects, shell builtins, or
&&chaining - The task still works after you strip shell syntax from a terminal one-liner
command is usually the safest starting point because Ansible does not pass the task through a shell:
|,>,;, and&&are not interpreted as operators- Quoting mistakes and accidental command injection are less likely
- Behavior is easier to predict across hosts
Official guidance says command should be preferred unless shell processing is explicitly required. It is also the right default in playbooks when a dedicated module does not exist but the task does not need shell parsing.
When to Use the shell Module
Use shell when the task genuinely needs shell syntax:
- Pipes (
df -h / | tail -1) - Redirects (
echo line >> /etc/sysctl.d/99-demo.conf) - Chaining (
cd /opt && ./installer.sh) - Shell builtins (
source,ulimit)
The tradeoff is that the remote shell interprets the whole command string:
- Quoting and variable expansion become your responsibility
- Return codes can reflect shell behavior, not just the program you meant to run
- A task that works on one host can fail on another when
/bin/shdiffers or the line needs Bash-only syntax
Use shell only when the shell is part of the requirement, not just because the command was copied from a terminal. If you can express the task as one executable plus arguments, command is still the better fit.
When to Use the raw Module
Use raw only when:
- Python is not installed and you need to bootstrap it before other modules work
- The target is a network device or appliance with no Python interpreter
- You are debugging SSH connectivity at the lowest level
Think of raw as a bootstrap or emergency path, not a normal automation style:
- Behaves closer to typing a command over SSH than running a normal Ansible module
- Works before Python exists, but lacks handlers, rich return data, and predictable
changedsemantics - Best used once per host—install what is needed, then switch to
command,shell, or purpose-built modules
For every other case on Linux with Python, use command or shell—or better, a purpose-built module.
command Module Explained
Run a simple command
Run uptime on every host in the lab group. Need a reproducible two-node lab? The EX294 lab setup is optional—any SSH-enabled inventory works.
ansible lab -m command -a "uptime"Sample output:
rocky2 | CHANGED | rc=0 >>
18:53:19 up 20:56, 1 user, load average: 0.52, 0.17, 0.06CHANGED here only means the command ran and returned exit code 0—it does not mean the system configuration changed.
Use argv for safer arguments
When arguments contain spaces or characters the shell would misread, pass argv as a list instead of one string. In ad hoc mode, use JSON:
ansible lab -m command -a '{"argv": ["echo", "hello world"]}'Sample output:
rocky2 | CHANGED | rc=0 >>
hello worldIn a playbook, YAML list syntax is clearer:
- name: Greet with spaces in the argument
ansible.builtin.command:
argv:
- echo
- hello worldargv is useful when a single command string is hard to quote safely:
- Each list item becomes one argument—Ansible does not guess how to split the line
- Spaces, quotes, and odd characters in paths or usernames stay intact
- Variables passed as arguments avoid one long concatenated string and broken quoting
Use chdir
Run a command in another directory without shell cd:
ansible lab -m command -a "pwd chdir=/tmp"Sample output:
rocky2 | CHANGED | rc=0 >>
/tmpPrefer chdir over shell: cd /path && command when no other shell feature is needed:
- The directory change applies only to that task—no subshell side effects
- The command still runs without shell parsing
- Common for vendor installers, local scripts, and build commands that must run from a specific directory
Use creates for idempotency
creates tells Ansible to skip the command when a marker file already exists. The command runs only if that path is missing—handy for install scripts or one-time setup you do not want to repeat.
Create /tmp/cmd-demo only if it is not already there:
ansible lab -m command -a "touch /tmp/cmd-demo creates=/tmp/cmd-demo" -bSample output:
rocky2 | CHANGED | rc=0 >>Run the same line again:
ansible lab -m command -a "touch /tmp/cmd-demo creates=/tmp/cmd-demo" -bSample output:
rocky2 | SUCCESS | rc=0 >>
skipped, since /tmp/cmd-demo existsAnsible did not run touch because the path already existed—the task is idempotent for this simple pattern.
Use removes for idempotency
removes is the mirror image of creates. Ansible runs the command only when the path you name still exists on the host. If that file is already gone, the task is skipped—so you can rerun a cleanup command without errors or repeat work.
Delete /tmp/cmd-demo, but only while the file is still there:
ansible lab -m command -a "rm /tmp/cmd-demo removes=/tmp/cmd-demo" -bSample output:
rocky2 | CHANGED | rc=0 >>Run the same line again after the file is gone:
ansible lab -m command -a "rm /tmp/cmd-demo removes=/tmp/cmd-demo" -bSample output:
rocky2 | SUCCESS | rc=0 >>
skipped, since /tmp/cmd-demo does not existAnsible did not run rm because nothing was left to delete—the task is idempotent for this simple pattern.
Register command output
Ad hoc mode prints stdout inline. Save and run the playbook:
cat > /tmp/register-demo.yml << 'EOF'
---
- hosts: lab
gather_facts: false
tasks:
- name: Capture uptime
ansible.builtin.command: uptime
register: uptime_out
- name: Show stdout
ansible.builtin.debug:
var: uptime_out.stdout
EOFansible-playbook /tmp/register-demo.ymlSample output:
TASK [Capture uptime] **********************************************************
changed: [rocky2]
TASK [Show stdout] *************************************************************
ok: [rocky2] => {
"uptime_out.stdout": " 18:53:00 up 20:56, 1 user, load average: 0.37, 0.12, 0.04"
}Use uptime_out.rc, uptime_out.stderr, and uptime_out.stdout_lines in later tasks:
rc— exit code for success/failure checksstdout/stdout_lines— command output fordebug,set_fact, orwhenconditionsstderr— error text when the command failed partially or wrote warnings
For the full result hash, loop output, and magic variables that pair with register, see register and magic variables.
shell Module Explained
Run shell commands
shell runs a string through the remote shell—here, print $SHELL and the root filesystem line from df:
ansible lab -m shell -a 'echo $SHELL && df -h / | tail -1'Sample output:
rocky2 | CHANGED | rc=0 >>
/bin/bash
/dev/mapper/rlm-root 14G 2.3G 11G 17% /Environment variable expansion and && chaining work because a shell parses the string.
Use pipes and redirects
The same pipeline fails under command because | is passed to df as a literal argument:
ansible lab -m command -a "df -h / | tail -1"Sample output:
rocky2 | FAILED | rc=1 >>
df: invalid option -- '1'
Try 'df --help' for more information.Under shell, the pipe connects two programs:
ansible lab -m shell -a "df -h / | tail -1"Sample output:
rocky2 | CHANGED | rc=0 >>
/dev/mapper/rlm-root 14G 2.3G 11G 17% /Prefer splitting into two command tasks when you can—reserve pipes for cases where a single shell line is genuinely simpler.
Use shell variables and globbing
shell expands $VAR, *, and ~ on the managed host. command treats pipes, redirects, globbing, and shell metacharacters literally—the remote program does not see shell parsing for those.
command can expand environment-variable style arguments such as $HOME through expand_argument_vars by default. This is not full shell parsing: pipes, redirects, globbing, &&, and shell builtins still require shell.
Use shell for for loops, brace expansion, and other shell-only syntax. When you only need one program, command avoids accidental expansion.
Use executable to choose a shell
Point the task at a specific interpreter when /bin/sh is not what you need:
ansible lab -m shell -a "echo test executable=/bin/bash"Sample output:
rocky2 | CHANGED | rc=0 >>
testPlaybook equivalent:
- name: Run with bash
ansible.builtin.shell: echo test
args:
executable: /bin/bashSet executable when Bash-only syntax is required but the remote default is /bin/sh:
- Needed for
source, arrays,[[ ... ]], brace expansion, or Bash-specific redirection - Do not set
executable: /bin/bashby habit—if the task does not need Bash syntax, prefercommandor a POSIX-compatible line - Must be an absolute path; see the shell module documentation
Use creates and removes
creates and removes gate whether the shell command runs at all—same as with command:
- Use when the command creates or removes a predictable file, directory, certificate, archive, or install marker
- Ansible checks the path before running the command
- Choose a marker that proves the operation really completed—not a random temp file that can skip work when the app is not actually installed
- With either option set,
shellgets partial check mode support—the task is skipped in--checkwhen the guard says no work is needed
Register shell output
Registration is identical to command:
- name: Capture disk line
ansible.builtin.shell: df -h / | tail -1
register: disk_line
- name: Print result
ansible.builtin.debug:
msg: "{{ disk_line.stdout }}"Treat stdout as untrusted text when you pass it into later tasks or templates.
raw Module Explained
Run commands without Python
raw SSHes a command string directly—it never loads the Ansible module payload on the target. Kernel version on a managed host:
ansible lab -m raw -a "uname -r"Sample output:
rocky2 | CHANGED | rc=0 >>
6.12.0-211.16.1.el10_2.0.1.x86_64You may also see Shared connection to … closed in the output—that is connection teardown noise, not command stderr.
Bootstrap Python on managed nodes
When Python is missing, normal modules fail during interpreter discovery. On RHEL/Rocky-style systems, install Python first:
- name: Bootstrap Python for Ansible modules
ansible.builtin.raw: dnf install -y python3
become: true
changed_when: falseIf you plan to use package-management modules immediately after bootstrap, also install the DNF Python bindings your OS expects:
- Older DNF-based systems —
python3-libdnfforansible.builtin.dnf - DNF5-based systems —
python3-libdnf5foransible.builtin.dnf5 - Install the binding that matches the package module you will use next
changed_when: false is optional, but useful because bootstrap probes often otherwise report changed every time. Run that once per host that lacks an interpreter.
Use raw for network or minimal systems
Routers, switches, and embedded appliances often have no Python. raw (or vendor-specific connection plugins) may be the only option for one-line probes such as show version.
Keep those tasks small and move repeatable logic into vendor collections when they exist.
Limitations of raw module
raw is intentionally thin:
- No handler
notifysupport - No check mode
- No module args validation like
createson other modules (pass shell logic yourself) - Return data is basic command output such as stdout, stderr, and rc—no rich module-specific facts
- Security and quoting are your responsibility—the string runs through the configured remote shell
Once Python is present, stop using raw for routine administration.
command vs shell: Main Differences
Most confusion comes from commands that work in a terminal but fail in Ansible:
- In a terminal, your shell interprets pipes, redirects, variables, and chaining before the program runs
- The
commandmodule skips that shell layer—metacharacters are usually passed as literal arguments, and the target program may reject them - If a one-liner needs shell parsing, move it to
shellor split it into simplercommandtasks
| Topic | command |
shell |
|---|---|---|
| Invocation | exec style—program + args |
String parsed by /bin/sh |
| Pipes and redirects | Not supported | Supported |
creates / removes |
Yes | Yes |
| Default safety | Higher—no shell metacharacters | Lower—shell injection risk |
| When both seem to work | Prefer command |
Only if shell syntax is required |
Official guidance: use command unless you need <, >, |, ;, or && in the task string.
shell vs raw: Main Differences
shell is still a normal Ansible module; raw is not:
shellruns through the module subsystem and returns full task JSONrawSSHes a string directly—less predictable and harder to standardize across playbooks- When Python exists on the host, use
shellfor shell syntax andcommandfor plain executables—notraw
| Topic | shell |
raw |
|---|---|---|
| Python required on target | Yes | No |
| Module subsystem | Yes | No—direct SSH command |
| Structured return values | Full JSON | Basic stdout/stderr/rc |
| Handlers | Supported | Not supported |
| Typical Linux admin | Daily driver when pipes are needed | Bootstrap and appliances only |
Idempotency with command and shell
Neither module tracks package or file state by itself—every successful run defaults to changed: true unless you add guards. Pick the guard that matches what you are trying to prove:
creates— skip when a marker file already exists (installers, one-time setup)removes— skip when a marker file is already gone (cleanup)changed_when— overridechangedwhen return code alone is misleadingfailed_when— treat expected non-zero exit codes as success (probes)
Use creates
Gate installation or setup scripts on a marker file:
- name: Run installer once
ansible.builtin.command: /opt/app/install.sh
args:
creates: /opt/app/installedSame idea works in ad hoc mode: creates=/path in the -a string. With creates or removes set, command and shell also get partial check mode support—the task is skipped in --check when the file guard says no work is needed.
Use removes
Gate cleanup tasks on file presence—shown earlier with rm /tmp/cmd-demo removes=/tmp/cmd-demo.
Use changed_when
When return code alone does not describe change, register and override changed:
- name: Check if marker exists
ansible.builtin.command: test -f /tmp/marker
register: marker
failed_when: false
changed_when: false
- name: Create marker only when missing
ansible.builtin.command: touch /tmp/marker
when: marker.rc != 0First run creates the file; second run skips the touch task because marker.rc is 0.
Use failed_when
Treat expected non-zero exit codes as success—useful for probes:
- name: Probe optional file
ansible.builtin.command: test -f /etc/nonexistent-demo
register: probe
failed_when: false
changed_when: false
- name: Report missing file
ansible.builtin.debug:
msg: File is missing
when: probe.rc != 0Without failed_when: false, test -f on a missing file would stop the play.
Safer Module Alternatives
This is the main difference between writing a shell script and writing an Ansible playbook. A shell command describes an action to run; an Ansible module usually describes the desired final state. That makes the playbook easier to rerun and easier to review.
Before you embed shell in a playbook, check for a module that already encodes the desired state. File edits that would use sed through shell belong in lineinfile, blockinfile, replace, and template instead:
| Instead of | Prefer |
|---|---|
shell: touch /path |
ansible.builtin.file: path=/path state=touch |
shell: rm -rf /path |
ansible.builtin.file: path=/path state=absent |
shell: dnf install -y pkg |
ansible.builtin.dnf: name=pkg state=present |
shell: systemctl restart httpd |
ansible.builtin.service: name=httpd state=restarted |
shell: sed -i … |
ansible.builtin.lineinfile or ansible.builtin.replace |
shell: echo … > file |
ansible.builtin.copy or ansible.builtin.template |
shell: useradd appuser |
ansible.builtin.user: name=appuser state=present |
shell: curl … | bash |
ansible.builtin.get_url + command with validation |
Dedicated modules return clearer changed semantics and support check mode where applicable.
How ansible-lint Sees command and shell
ansible-lint may warn when shell is used but no shell feature is needed. It may also recommend package, service, file, or other purpose-built modules instead of command. Treat these warnings as a sign that the task can probably become more idempotent and easier to review.
Official Ansible Lint ships both command-instead-of-shell and command-instead-of-module rules.
Security Risks with shell and raw
Shell strings are a common injection surface. If any part of -a or the playbook shell: line comes from a variable an attacker can influence, they may append ; rm -rf / or exfiltrate data.
Mitigations:
- Prefer
commandwithargvover string concatenation - Never pass unchecked user input into
shellorraw - Use
no_log: trueon tasks that handle secrets (still avoid shell when possible) - Quote variables in playbooks (
{{ var | quote }}) when a shell line is unavoidable - Treat
rawlike typing directly into an SSH session—minimal use, tight privileges
The Ansible command module documentation explicitly warns that parsing through a shell is a security risk when metacharacters are involved.
Best Practices for command, shell and raw
Default module choice:
command— when no dedicated module exists and no shell syntax is requiredshell— only for pipes, redirects, chaining, variable expansion, or builtinsraw— only before normal Ansible modules can run on the target
Reusable playbook habits:
- Add
creates,removes,changed_when, orfailed_whenwhere a bare command would rerun every time - Prefer
file,dnf,service,copy,template,lineinfile, anduserwhen they describe the desired state directly
Practical Decision Guide
Sample output:
Need to run something on the host?
│
├─ Is Python missing or is this a no-Python appliance?
│ └─ Yes → raw (bootstrap only), then switch to normal modules
│
├─ Does the task need |, >, &&, $VAR, or shell builtins?
│ ├─ Yes → shell (or refactor into a module + command)
│ └─ No → command
│
└─ Does ansible.builtin.* already model this state?
└─ Yes → use that module insteadWhen in doubt, start with command. Escalate to shell only for proven shell syntax. Use raw once per host for Python bootstrap, not for daily config.
Common Mistakes and Fixes
| Symptom | Likely cause | Fix |
|---|---|---|
df: invalid option with a pipe in the task |
Used command with | |
Switch to shell, or run two command tasks |
Task reports changed every playbook run |
command/shell always mark changed on success |
Add creates/removes, use a real module, or set changed_when |
skipped, since /path exists but work did not happen |
creates points at wrong path |
Fix the marker path or remove stale file |
argv=pwd fails ad hoc |
Passed argv as key=value string | Use JSON: -a '{"argv": ["pwd"]}' |
raw works; ping fails |
Python not installed | Bootstrap with raw, then use normal modules |
| Shell task works on one host, fails on another | Different /bin/sh (dash vs bash) |
Set executable: /bin/bash or avoid bash-only syntax |
When to Convert command or shell Tasks to Proper Modules
Move tasks out of command/shell when:
- The same command appears in more than one play or role
- You need full, reliable check mode beyond simple
creates/removesfile guards - You need reliable
changedfor reporting or CI gates - The task manages files, packages, services, or users—
file,dnf,service, anduseralready exist - Reviewers cannot tell intent from opaque shell one-liners
Keep command when you wrap a vendor installer with creates, or when no module covers a read-only diagnostic (uptime, custom health script with stable output).
Summary
Use command for plain executables, shell when pipes or shell syntax are required, and raw only to bootstrap Python or talk to hosts without interpreters. Add creates, removes, or changed_when so repeated runs behave predictably—the idempotency guide covers more changed_when patterns. Prefer file, dnf, service, and similar modules over shell whenever they model the task—your playbooks become safer, clearer, and easier to review.
References
- ansible.builtin.command — Ansible documentation
- ansible.builtin.shell — Ansible documentation
- ansible.builtin.raw — Ansible documentation
- ansible.builtin.dnf5 — DNF5 module and
python3-libdnf5requirement - Ansible Lint —
command-instead-of-shellandcommand-instead-of-modulerules - Ansible ad hoc commands — syntax and quick module examples
- Ansible modules and ansible-doc — FQCN and module discovery
- command vs shell vs raw —
rawbootstrap walkthrough

