Ansible command vs shell vs raw Module: When to Use Each

Compare Ansible command, shell, and raw modules—pipes, argv, creates, removes, changed_when, Python bootstrap, security risks, and when to use dedicated modules instead.

Published

Updated

Read time 17 min read

Reviewed byDeepak Prasad

Ansible command, shell, and raw modules compared on Rocky Linux 10

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.

NOTE
This chapter is part of the GoLinuxCloud Ansible tutorial (RHCE EX294). Follow along from ~/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/sh differs 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 changed semantics
  • 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.

bash
ansible lab -m command -a "uptime"

Sample output:

output
rocky2 | CHANGED | rc=0 >>
 18:53:19 up 20:56,  1 user,  load average: 0.52, 0.17, 0.06

CHANGED 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:

bash
ansible lab -m command -a '{"argv": ["echo", "hello world"]}'

Sample output:

output
rocky2 | CHANGED | rc=0 >>
hello world

In a playbook, YAML list syntax is clearer:

yaml
- name: Greet with spaces in the argument
  ansible.builtin.command:
    argv:
      - echo
      - hello world

argv 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:

bash
ansible lab -m command -a "pwd chdir=/tmp"

Sample output:

output
rocky2 | CHANGED | rc=0 >>
/tmp

Prefer 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:

bash
ansible lab -m command -a "touch /tmp/cmd-demo creates=/tmp/cmd-demo" -b

Sample output:

output
rocky2 | CHANGED | rc=0 >>

Run the same line again:

bash
ansible lab -m command -a "touch /tmp/cmd-demo creates=/tmp/cmd-demo" -b

Sample output:

output
rocky2 | SUCCESS | rc=0 >>
skipped, since /tmp/cmd-demo exists

Ansible 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:

bash
ansible lab -m command -a "rm /tmp/cmd-demo removes=/tmp/cmd-demo" -b

Sample output:

output
rocky2 | CHANGED | rc=0 >>

Run the same line again after the file is gone:

bash
ansible lab -m command -a "rm /tmp/cmd-demo removes=/tmp/cmd-demo" -b

Sample output:

output
rocky2 | SUCCESS | rc=0 >>
skipped, since /tmp/cmd-demo does not exist

Ansible 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:

bash
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
EOF
bash
ansible-playbook /tmp/register-demo.yml

Sample output:

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 checks
  • stdout / stdout_lines — command output for debug, set_fact, or when conditions
  • stderr — 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:

bash
ansible lab -m shell -a 'echo $SHELL && df -h / | tail -1'

Sample output:

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:

bash
ansible lab -m command -a "df -h / | tail -1"

Sample output:

output
rocky2 | FAILED | rc=1 >>
df: invalid option -- '1'
Try 'df --help' for more information.

Under shell, the pipe connects two programs:

bash
ansible lab -m shell -a "df -h / | tail -1"

Sample output:

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.

NOTE
In ansible-core 2.16 and later, 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:

bash
ansible lab -m shell -a "echo test executable=/bin/bash"

Sample output:

output
rocky2 | CHANGED | rc=0 >>
test

Playbook equivalent:

yaml
- name: Run with bash
  ansible.builtin.shell: echo test
  args:
    executable: /bin/bash

Set 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/bash by habit—if the task does not need Bash syntax, prefer command or 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, shell gets partial check mode support—the task is skipped in --check when the guard says no work is needed

Register shell output

Registration is identical to command:

yaml
- 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:

bash
ansible lab -m raw -a "uname -r"

Sample output:

output
rocky2 | CHANGED | rc=0 >>
6.12.0-211.16.1.el10_2.0.1.x86_64

You 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:

yaml
- name: Bootstrap Python for Ansible modules
  ansible.builtin.raw: dnf install -y python3
  become: true
  changed_when: false

If you plan to use package-management modules immediately after bootstrap, also install the DNF Python bindings your OS expects:

  • Older DNF-based systems — python3-libdnf for ansible.builtin.dnf
  • DNF5-based systems — python3-libdnf5 for ansible.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 notify support
  • No check mode
  • No module args validation like creates on 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 command module 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 shell or split it into simpler command tasks
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:

  • shell runs through the module subsystem and returns full task JSON
  • raw SSHes a string directly—less predictable and harder to standardize across playbooks
  • When Python exists on the host, use shell for shell syntax and command for plain executables—not raw
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 — override changed when return code alone is misleading
  • failed_when — treat expected non-zero exit codes as success (probes)

Use creates

Gate installation or setup scripts on a marker file:

yaml
- name: Run installer once
  ansible.builtin.command: /opt/app/install.sh
  args:
    creates: /opt/app/installed

Same 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:

yaml
- 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 != 0

First 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:

yaml
- 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 != 0

Without 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 command with argv over string concatenation
  • Never pass unchecked user input into shell or raw
  • Use no_log: true on tasks that handle secrets (still avoid shell when possible)
  • Quote variables in playbooks ({{ var | quote }}) when a shell line is unavoidable
  • Treat raw like 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 required
  • shell — only for pipes, redirects, chaining, variable expansion, or builtins
  • raw — only before normal Ansible modules can run on the target

Reusable playbook habits:

  • Add creates, removes, changed_when, or failed_when where a bare command would rerun every time
  • Prefer file, dnf, service, copy, template, lineinfile, and user when they describe the desired state directly

Practical Decision Guide

Sample output:

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 instead

When 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/removes file guards
  • You need reliable changed for reporting or CI gates
  • The task manages files, packages, services, or users—file, dnf, service, and user already 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-libdnf5 requirement
  • Ansible Lint — command-instead-of-shell and command-instead-of-module rules
  • Ansible ad hoc commands — syntax and quick module examples
  • Ansible modules and ansible-doc — FQCN and module discovery
  • command vs shell vs raw — raw bootstrap walkthrough

Frequently Asked Questions

1. What is the difference between Ansible command and shell modules?

command runs an executable directly without a shell—no pipes, redirects, or shell metacharacters. shell runs through /bin/sh (or another executable) when you need pipes, &&, variables, or shell builtins.

2. When should I use the raw module in Ansible?

Use raw only when Python is missing on the managed node, for network devices without Python, or for one-off bootstrap tasks. It bypasses the normal module layer and SSHes a command string directly.

3. Does the command module support pipes?

No. A pipe character is passed as a literal argument to the program, which usually fails. Use shell when you need pipelines, or split the work into separate command tasks.

4. How do I make command or shell idempotent?

Use creates or removes when a file marker defines whether the command should run. For finer control, register the result and set changed_when or failed_when in a playbook task.

5. Is shell safer than raw in Ansible?

For normal Linux hosts with Python, yes—shell and command use the module subsystem with structured JSON return values. raw is a low-level escape hatch with no handler support and fewer safety checks.

6. What module should I use instead of shell for editing files?

Prefer ansible.builtin.lineinfile, blockinfile, or copy/template instead of sed or echo redirects through shell—they are idempotent and easier to review.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive …