Introduction

Ansible has two modules for running commands on remote hosts: command and shell. They look similar but behave differently. command runs a binary directly (safer, faster). shell runs through /bin/sh (supports pipes, redirects, environment variables). Choosing the wrong one is a common source of bugs.

The Key Difference

# command — runs the binary directly (no shell processing)
- ansible.builtin.command: ls /tmp/*.log
  # FAILS — globbing (*.log) requires a shell

# shell — runs through /bin/sh (full shell features)
- ansible.builtin.shell: ls /tmp/*.log
  # WORKS — shell expands *.log
Featurecommandshell
Pipes (|)❌✅
Redirects (>, >>)❌✅
Globbing (*.log)❌✅
Environment variables ($HOME)❌✅
Chained commands (&&, ;)❌✅
Subshells ($(...))❌✅
Shell injection riskLowHigher
PerformanceSlightly fasterSlightly slower

When to Use command

Use command for simple, single-binary execution:

---
- name: command module examples
  hosts: all
  tasks:
    - name: Check disk space
      ansible.builtin.command: df -h /
      register: disk

    - name: Run application binary
      ansible.builtin.command: /opt/myapp/bin/start --port 8080

    - name: Check service status
      ansible.builtin.command: systemctl is-active nginx
      register: nginx_status
      ignore_errors: true

    - name: Run with specific working directory
      ansible.builtin.command:
        cmd: ./manage.py migrate
        chdir: /opt/app

    - name: Run only if file doesn't exist
      ansible.builtin.command: /opt/setup.sh
      args:
        creates: /opt/app/.installed

    - name: Run only if file exists
      ansible.builtin.command: /opt/cleanup.sh
      args:
        removes: /opt/app/tmp/cache

When to Use shell

Use shell when you need shell features:

---
- name: shell module examples
  hosts: all
  tasks:
    # Pipes
    - name: Count running processes
      ansible.builtin.shell: ps aux | wc -l
      register: proc_count

    - name: Find large files
      ansible.builtin.shell: find /var/log -name "*.log" -size +100M | head -10
      register: large_files

    # Redirects
    - name: Export database
      ansible.builtin.shell: pg_dump mydb > /backup/mydb.sql 2>&1

    - name: Append to log
      ansible.builtin.shell: echo "Deploy at $(date)" >> /var/log/deploys.log

    # Globbing
    - name: Remove old logs
      ansible.builtin.shell: rm -f /var/log/myapp/*.log.gz

    # Environment variables
    - name: Use environment variable
      ansible.builtin.shell: echo $HOME/.config/myapp

    # Chained commands
    - name: Build and install
      ansible.builtin.shell: cd /opt/src && make && make install

    # Subshell
    - name: Get process ID
      ansible.builtin.shell: kill $(cat /var/run/myapp.pid)
      ignore_errors: true

Safer shell Usage

Avoid Shell Injection

# DANGEROUS — user input could contain shell metacharacters
- ansible.builtin.shell: "grep {{ user_input }} /etc/passwd"
  # If user_input = "; rm -rf /" → disaster

# SAFER — use command module when possible
- ansible.builtin.command: "grep {{ user_input }} /etc/passwd"
  # command doesn't process shell metacharacters

# SAFEST — use quote filter
- ansible.builtin.shell: "grep {{ user_input | quote }} /etc/passwd"

Use executable Parameter

# Use bash instead of /bin/sh (for bash-specific features)
- ansible.builtin.shell: |
    shopt -s nullglob
    files=(/opt/app/logs/*.log)
    echo "${#files[@]} log files"
  args:
    executable: /bin/bash

Common Parameters (Both Modules)

- ansible.builtin.command:
    cmd: /opt/app/start.sh        # Command to run
    chdir: /opt/app               # Working directory
    creates: /opt/app/.running    # Skip if file exists
    removes: /opt/app/.stopped    # Skip if file doesn't exist
  register: result                 # Capture output
  changed_when: "'changed' in result.stdout"  # Custom changed detection
  failed_when: result.rc > 1      # Custom failure condition
  ignore_errors: true              # Don't fail the play
  become: true                     # Run as root
  environment:                     # Set environment variables
    PATH: "/opt/app/bin:{{ ansible_env.PATH }}"
    APP_ENV: production

Handling Output

- name: Run command and capture output
  ansible.builtin.command: cat /etc/os-release
  register: os_info

- name: Use the output
  ansible.builtin.debug:
    msg:
      stdout: "{{ os_info.stdout }}"
      stdout_lines: "{{ os_info.stdout_lines }}"  # List of lines
      stderr: "{{ os_info.stderr }}"
      rc: "{{ os_info.rc }}"                      # Return code
      changed: "{{ os_info.changed }}"

Idempotency (creates/removes)

Both modules are not idempotent by default — they run every time. Use creates or removes to make them conditional:

# Only runs if /opt/app doesn't exist
- ansible.builtin.command: /opt/install.sh
  args:
    creates: /opt/app

# Only runs if the lock file exists
- ansible.builtin.command: /opt/cleanup.sh
  args:
    removes: /tmp/myapp.lock

# Custom idempotency with changed_when
- ansible.builtin.shell: |
    if [ -f /opt/app/.configured ]; then
      echo "already configured"
    else
      /opt/configure.sh && touch /opt/app/.configured
      echo "configured"
    fi
  register: config_result
  changed_when: "'configured' == config_result.stdout_lines[-1]"

Better Alternatives

Before using command or shell, check if a dedicated module exists:

Instead of...Use...
command: apt install nginxansible.builtin.apt: name=nginx
command: systemctl restart nginxansible.builtin.service: name=nginx state=restarted
command: useradd aliceansible.builtin.user: name=alice
shell: echo "text" > /tmp/fileansible.builtin.copy: content="text" dest=/tmp/file
shell: sed -i 's/old/new/' fileansible.builtin.lineinfile or replace
shell: curl https://...ansible.builtin.get_url or uri
command: mkdir -p /opt/appansible.builtin.file: state=directory
command: cp /a /bansible.builtin.copy: remote_src=true

Dedicated modules are idempotent, cross-platform, and provide better error handling.

Multiline Commands

# shell with multiline (using |)
- ansible.builtin.shell: |
    cd /opt/app
    source venv/bin/activate
    python manage.py migrate
    python manage.py collectstatic --noinput
  args:
    executable: /bin/bash

# command doesn't support multiline well — use shell for that

Troubleshooting

"No such file or directory" with command

Globbing or ~ doesn't work in command:

# FAILS
- ansible.builtin.command: ls ~/Documents/*.pdf

# FIX — use shell
- ansible.builtin.shell: ls ~/Documents/*.pdf

# OR — use full path without glob
- ansible.builtin.command: ls /home/alice/Documents/report.pdf

"[WARNING] Consider using apt/yum module rather than running command"

Ansible detects you're using command/shell for something a module handles better. Switch to the dedicated module.

"changed" on Every Run

Both modules always report changed: true. Fix with changed_when:

- ansible.builtin.command: /opt/app/check-status.sh
  register: status
  changed_when: false  # This is a read-only check

Conclusion

Use command for simple binary execution — it's safer and faster. Use shell when you need pipes, redirects, globbing, or environment variables. Always check if a dedicated Ansible module exists first (apt, service, file, copy, etc.) — they're idempotent and cross-platform. Use creates/removes or changed_when to make command/shell tasks idempotent. When using shell with user input, always apply the | quote filter.