AnsibleIX · command, shell and rawcommand, shell and raw
shell: when you genuinely need a shell, and what you accept
What you'll learn
- Identify the three legitimate reasons to choose shell over command
- Explain which shell interprets the string, and why that varies by distribution
- Use the executable option to pin an interpreter, and know when that is needed
- Recognise pipeline exit-status traps that make a failing task report success
- Apply the course rule for choosing between command and shell
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.shell is ansible.builtin.command with one difference:
the string is handed to a shell on the managed node instead of being
split and executed directly.
That one difference buys you pipes, redirection, globbing, command
substitution, shell builtins and &&. It also transfers the entire
correctness burden of shell quoting from Ansible to you, on every
distribution in your fleet, forever. This lesson is about deciding
whether that trade is worth making for a given task, and doing it
deliberately rather than by reflex.
The three cases that justify it
There are exactly three things shell does that command cannot, and
if your task needs none of them, it does not need shell.
| Need | Example | Why command cannot |
|---|---|---|
| A pipeline | journalctl -u app | grep -c OOM | The pipe is shell syntax. |
| A redirect | appctl dump > /var/lib/app/state.json | So is >. |
| A shell builtin | ulimit -n, type -P foo | The builtin is not a program on disk. |
Globbing and command substitution belong to the same list, but both are
usually a sign that the work belongs somewhere else — find, the
ansible.builtin.find module, or a registered variable.
Which shell, exactly
The documentation says the command runs “through a shell (/bin/sh) on
the remote node”. That is precise and it is not reassuring, because
/bin/sh is a different program on different systems.
| Family | /bin/sh is usually | Consequences |
|---|---|---|
| Debian, Ubuntu | dash | No [[ ]], no arrays, no local -n, no pipefail in older versions, no source (use .). |
| RHEL, Rocky, Alma | bash in POSIX mode | Most bashisms happen to work, which is how the bug gets shipped. |
| Alpine | busybox ash | Its own subset again. |
A play written and tested on Rocky and then run on Ubuntu is the classic way to discover this. The task does not fail with “bashism not supported”; it fails with a syntax error, or worse, it succeeds and produces the wrong answer.
- name: Count OOM kills since boot
ansible.builtin.shell:
cmd: journalctl -k --since=boot | grep -c 'Out of memory' || true
executable: /bin/bash
register: oom_count
changed_when: falseexecutable is not a magic portability fix — it is a declaration. It
says “this string is bash, and the host must have bash at this path”. If
a host in the group does not, the task fails loudly, which is a better
outcome than silently running under dash and getting a different
answer.
The exit status trap
This one produces a green playbook over a broken host, and it is the
most expensive shell mistake in production because nothing reports it.
A pipeline’s exit status is the exit status of its last command. The shell does not care that something earlier in the chain failed.
# Wrong: the task reports ok even when appctl does not exist.
- name: Read the current queue depth
ansible.builtin.shell: appctl stats | awk '/queue/ {print $2}'
register: queue_depth
changed_when: falseregister then hands you a queue_depth.stdout that is the empty
string, and every when: and every template downstream quietly treats
an outage as “queue depth is nothing”.
Two fixes, both explicit:
# Option 1: ask for the behaviour you assumed, and pin a shell that has it.
- name: Read the current queue depth
ansible.builtin.shell:
cmd: set -o pipefail && appctl stats | awk '/queue/ {print $2}'
executable: /bin/bash
register: queue_depth
changed_when: false
# Option 2: no pipeline, no shell, no trap.
- name: Read the raw stats
ansible.builtin.command: /usr/sbin/appctl stats
register: app_stats
changed_when: false
- name: Extract the queue depth on the controller
ansible.builtin.set_fact:
queue_depth: "{{ app_stats.stdout | regex_search('queue\\s+(\\d+)', '\\1') | first }}"Option 2 is the better one more often than people expect. The transformation runs on the controller, where you can debug it, where it is the same on every host in the fleet, and where a change to it does not require touching a managed node at all.
Quoting is now your job, per host
Under command, an argument containing a space is one argument because
shlex was told so, or because you used argv. Under shell, an
argument containing a space is however many words the target’s shell
decides it is.
# shell: /bin/sh sees rm -rf /srv/app data/current
- name: Remove the old release
ansible.builtin.shell: rm -rf {{ app_dir }}/current
# command with argv: one argument, whatever it contains
- name: Remove the old release
ansible.builtin.command:
argv:
- /bin/rm
- -rf
- "{{ app_dir }}/current"The next lesson is entirely about this, because the space is the benign version of the problem and the semicolon is the other one.
Blast radius
shell runs on every targeted host in parallel and can do anything the
remote user can do. Two habits keep that bounded:
# 1. Exactly which hosts?
ansible-playbook -i inventories/prod deploy.yml --limit 'webservers:&europe' --list-hosts
# 2. What does the string become for one of them?
ansible-playbook -i inventories/prod deploy.yml --limit web01.example.com --tags render-onlyThe second habit — a debug task that prints the assembled command
string before any task runs it — costs one task and has caught more
production mistakes than any linter. Lesson 3 shows how to build it.
Knowledge check
Knowledge check · 4 questions
Q1. A task runs ansible.builtin.shell: appctl stats | awk "/queue/ {print $2}" and appctl is not installed on one host. What does the playbook report for that host?
Q2. Which of these tasks genuinely require ansible.builtin.shell rather than ansible.builtin.command? Select all that apply.
Q3. On a Debian or Ubuntu managed node, a shell task with no executable option is interpreted by dash rather than bash.
Q4. What does check mode do to an unguarded ansible.builtin.shell task?
Passing score: 75%. Answers are checked in this browser.