Ansible shell vs command vs raw: When to Use Each Module (Comparison)
By Luca Berton · Published 2024-01-01 · Category: installation
Difference between Ansible shell, command, and raw modules. When to use shell vs command, pipes, redirects, environment variables.
Ansible provides three modules for running commands on remote hosts: command, shell, and raw. Each has different capabilities and security implications. Most of the time, you should use none of them — a purpose-built module is almost always better.
Quick Comparison
| Feature | command | shell | raw | |---------|---------|-------|-----| | Shell processing | No | Yes | Yes (SSH-level) | | Pipes & redirects | No | Yes | Yes | | Environment variables | Partial | Yes | Depends on shell | | Glob expansion | No | Yes | Yes | | Python required | Yes | Yes | No | | Idempotent by default | No | No | No | | changed_when support | Yes | Yes | Yes | | Security risk | Low | Medium | High | | creates/removes | Yes | Yes | No |
See also: Ansible for Windows: Complete Guide to Windows Automation (2026)
Module 1: command (Default Choice)
command runs a command directly — no shell processing. This is the safest option.
- name: Check disk space
ansible.builtin.command: df -h /
register: disk_space
changed_when: false
- name: Get hostname
ansible.builtin.command: hostname -f
register: fqdn
changed_when: false
- name: Run a binary with arguments
ansible.builtin.command:
cmd: /opt/app/bin/migrate --direction up
chdir: /opt/app
register: migration
What command Can't Do
# ❌ FAILS — no pipe support
- ansible.builtin.command: cat /etc/passwd | grep root
# ❌ FAILS — no redirect
- ansible.builtin.command: echo "hello" > /tmp/file.txt
# ❌ FAILS — no glob expansion
- ansible.builtin.command: ls /tmp/*.log
# ❌ FAILS — no environment variable expansion
- ansible.builtin.command: echo $HOME
Why command Is Safer
# With shell, this is dangerous:
- ansible.builtin.shell: "rm -rf {{ user_input }}/"
# If user_input contains "; rm -rf /" → catastrophic
# With command, shell injection is impossible:
- ansible.builtin.command: "rm -rf {{ user_input }}/"
# Arguments are passed directly, no shell interpretation
Module 2: shell (When You Need Shell Features)
shell runs commands through /bin/sh. Use it only when you need pipes, redirects, or shell features.
- name: Find large files (pipe needed)
ansible.builtin.shell: find /var/log -size +100M | head -20
register: large_files
changed_when: false
- name: Redirect output to file
ansible.builtin.shell: mysqldump mydb > /tmp/backup.sql
args:
creates: /tmp/backup.sql
- name: Use environment variable
ansible.builtin.shell: source /opt/app/.env && /opt/app/bin/start
args:
executable: /bin/bash
Making shell Idempotent
# ❌ BAD — always reports changed
- name: Create user
ansible.builtin.shell: useradd appuser
# ✅ GOOD — creates prevents re-execution
- name: Create user
ansible.builtin.shell: useradd appuser
args:
creates: /home/appuser
# ✅ GOOD — changed_when based on output
- name: Run database migration
ansible.builtin.shell: /opt/app/bin/migrate up
register: migrate_result
changed_when: "'Migrated' in migrate_result.stdout"
# ✅ BEST — use the actual user module
- name: Create user
ansible.builtin.user:
name: appuser
state: present
See also: Ansible debug vs assert: When to Use Each Module
Module 3: raw (Last Resort)
raw sends commands over SSH without using the Ansible module system. No Python required on the remote host.
# Install Python on a minimal system (bootstrap)
- name: Install Python for Ansible
ansible.builtin.raw: apt-get update && apt-get install -y python3
become: true
changed_when: false
# Network device without Python
- name: Show interface status
ansible.builtin.raw: show interface brief
register: interface_status
When raw Is Appropriate
• Bootstrapping — installing Python on a fresh system • Network devices — routers/switches without Python • Minimal containers — Alpine/scratch images before Python install • Emergency access — when Ansible module subsystem is brokenThe Golden Rule: Use Purpose-Built Modules
Before reaching for command/shell/raw, check if a module exists:
# ❌ shell — fragile, not idempotent
- ansible.builtin.shell: apt-get install -y nginx
# ✅ Module — idempotent, cross-platform
- ansible.builtin.apt:
name: nginx
state: present
# ❌ shell
- ansible.builtin.shell: systemctl restart nginx
# ✅ Module
- ansible.builtin.systemd:
name: nginx
state: restarted
# ❌ shell
- ansible.builtin.shell: cp /tmp/file /etc/file && chmod 644 /etc/file
# ✅ Module
- ansible.builtin.copy:
src: /tmp/file
dest: /etc/file
mode: '0644'
remote_src: true
# ❌ shell
- ansible.builtin.shell: "echo '{{ line }}' >> /etc/config"
# ✅ Module
- ansible.builtin.lineinfile:
path: /etc/config
line: "{{ line }}"
See also: Ansible builtin command Module: Complete Guide with Examples and Best Practices
Common Module Alternatives
| Instead of shell/command | Use this module |
|------------------------|----------------|
| apt-get install | ansible.builtin.apt |
| yum install | ansible.builtin.yum / dnf |
| systemctl start/stop | ansible.builtin.systemd |
| cp / mv | ansible.builtin.copy |
| echo >> file | ansible.builtin.lineinfile |
| useradd / usermod | ansible.builtin.user |
| groupadd | ansible.builtin.group |
| chmod / chown | ansible.builtin.file |
| mount | ansible.posix.mount |
| crontab -e | ansible.builtin.cron |
| sysctl -w | ansible.posix.sysctl |
| git clone | ansible.builtin.git |
| pip install | ansible.builtin.pip |
| docker run | community.docker.docker_container |
| curl / wget | ansible.builtin.uri / get_url |
ansible-lint Warning: command-instead-of-module
ansible-lint flags unnecessary command/shell usage:
# ansible-lint will warn on this:
- ansible.builtin.command: chown root:root /etc/config
# Suggestion: Use ansible.builtin.file with owner/group parameters
FAQ
When should I use shell instead of command?
Use shell only when you need pipes (|), redirects (>, >>), glob patterns (*.log), environment variable expansion ($HOME), or shell built-ins (source, export). For everything else, command is safer.
Is command or shell idempotent?
Neither is idempotent by default — they always report "changed." Use creates:, removes:, or changed_when: to make them idempotent. Or better, use a purpose-built module that handles idempotency natively.
Why does ansible-lint complain about shell/command?
Because purpose-built modules are idempotent, cross-platform, and handle edge cases (error checking, dry run support). shell/command bypass all of that. ansible-lint encourages you to use the right module for the job.
Can I use bash-specific syntax with shell?
By default, shell uses /bin/sh. For bash features (arrays, [[ ]], process substitution), set executable: /bin/bash:
- ansible.builtin.shell: |
if [[ -f /etc/config ]]; then
source /etc/config
fi
args:
executable: /bin/bash
Conclusion
Use command when you must run a command and no module exists. Use shell only when you need shell features (pipes, redirects). Use raw only for bootstrapping or devices without Python. Always prefer purpose-built modules — they're idempotent, safer, and maintainable.
Related Articles
• Ansible copy vs template vs lineinfile • Ansible changed_when / failed_when Guide • Ansible Lint: Analyze & Fix Playbooks • Ansible Check Mode / Dry Run GuideAnsible shell Module Deep Dive
Running Shell Commands with Pipes and Redirects
- name: Find large files and sort by size
ansible.builtin.shell: find /var/log -type f -size +100M | sort -rn
register: large_files
- name: Process pipeline with grep
ansible.builtin.shell: ps aux | grep nginx | grep -v grep | wc -l
register: nginx_count
changed_when: false
- name: Redirect output to file
ansible.builtin.shell: df -h > /tmp/disk_report.txt 2>&1
Shell with Environment Variables
- name: Run with custom environment
ansible.builtin.shell: echo $MY_VAR && printenv | grep MY
environment:
MY_VAR: "custom_value"
PATH: "/usr/local/bin:{{ ansible_env.PATH }}"
Shell with Here Documents
- name: Multi-line shell script
ansible.builtin.shell: |
#!/bin/bash
set -euo pipefail
BACKUP_DIR="/backup/$(date +%Y%m%d)"
mkdir -p "$BACKUP_DIR"
for db in $(mysql -e "SHOW DATABASES" -s --skip-column-names); do
mysqldump "$db" > "$BACKUP_DIR/$db.sql"
done
echo "Backup completed: $(ls $BACKUP_DIR | wc -l) databases"
args:
executable: /bin/bash
register: backup_result
Ansible command Module Deep Dive
Command with chdir and creates
- name: Build application (skip if already built)
ansible.builtin.command:
cmd: make build
chdir: /opt/app/src
creates: /opt/app/bin/myapp
# Only runs if /opt/app/bin/myapp doesn't exist
Command with removes
- name: Run cleanup (only if temp files exist)
ansible.builtin.command:
cmd: /opt/scripts/cleanup.sh
removes: /tmp/app-cache
# Only runs if /tmp/app-cache exists
Command with argv (Safe Arguments)
- name: Safe argument passing (no shell injection)
ansible.builtin.command:
argv:
- /usr/bin/git
- clone
- --depth
- "1"
- "{{ repo_url }}"
- /opt/app
Security Comparison
| Risk | command | shell | raw | |------|---------|-------|-----| | Shell injection | ❌ Safe | ⚠️ Vulnerable | ❌ Safe | | Variable expansion | No | Yes | No | | Pipe/redirect abuse | Not possible | Possible | Not possible | | Audit trail | Clean | Complex commands harder to audit | Minimal |
Shell Injection Example
# DANGEROUS - user input in shell
- ansible.builtin.shell: "grep {{ user_input }} /etc/passwd"
# If user_input is "; rm -rf /" → disaster
# SAFE - use command module instead
- ansible.builtin.command: "grep {{ user_input }} /etc/passwd"
# Special characters are treated literally
Performance Tips
Use Pipelining
Enable SSH pipelining to reduce overhead for command/shell tasks:
# ansible.cfg
[ssh_connection]
pipelining = True
Batch Commands
# Slow — 3 SSH connections
- ansible.builtin.command: systemctl stop nginx
- ansible.builtin.command: cp /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak
- ansible.builtin.command: systemctl start nginx
# Faster — 1 SSH connection
- ansible.builtin.shell: |
systemctl stop nginx
cp /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak
systemctl start nginx
# Best — use proper modules (idempotent + no shell)
- ansible.builtin.service:
name: nginx
state: stopped
- ansible.builtin.copy:
src: /etc/nginx/nginx.conf
dest: /etc/nginx/nginx.conf.bak
remote_src: true
- ansible.builtin.service:
name: nginx
state: started
Ansible raw Module Deep Dive
Bootstrap Python on Remote Host
- name: Install Python on fresh Ubuntu
ansible.builtin.raw: apt-get install -y python3 python3-apt
become: true
- name: Install Python on fresh CentOS
ansible.builtin.raw: yum install -y python3
become: true
- name: Gather facts after Python is available
ansible.builtin.setup:
Network Device Commands
- name: Show running config on Cisco device
ansible.builtin.raw: show running-config
register: config
- name: Show interface status
ansible.builtin.raw: show ip interface brief
register: interfaces
Category: installation