Ansible command, shell and raw Modules
Three modules run things on a host and choosing between them is a recurring exam question. `command` runs a binary with no shell, `shell` runs a shell, and `raw` needs no Python at all. The interesting part is where the boundary really falls: `command` has no shell but does expand environment variables, which is not what it is usually described as doing.
Ad-hoc Commands and Modules Guide 7 of 45 Intermediate
- OSUbuntu 26.04 LTS
- ansible-core2.20.1
- Python3.14.4
- TimeAbout 16 min
- Reviewed23 August 2026
Written against the versions above. `expand_argument_vars` defaults to `true` and has since ansible-core 2.16. It expands environment variables in arguments before running the command, without involving a shell - and, as the module documentation puts it, an unmatched variable is "left unchanged, unlike shell substitution which would remove it".
| Server Name | IP Address | OS | Roles | CPU | RAM | HDD |
|---|---|---|---|---|---|---|
| ANS-CTL01 | 192.168.0.36 | Ubuntu 26.04 LTS | Ansible Control Node | 2 Core | 3 GB | 50 GB |
| ANS-A01 | 192.168.0.37 | Ubuntu 26.04 LTS | Managed Node (group: web) | 2 Core | 3 GB | 50 GB |
Before you start
- A working control node and one managed node.
- The session deliberately fails three commands and temporarily points
ansible_python_interpreterat a path that does not exist.
-
The configuration this guide assumes
Everything below runs from the home directory on ANS-CTL01, where two files sit side by side.
~/ansible.cfg- found because Ansible looks for./ansible.cfgin the current directory:[defaults] inventory = ./inventory.ini host_key_checking = False remote_user = sysadmin result_format = yamlresult_format = yamlrather than thestdout_callback = yamlthat older material recommends: that callback lived incommunity.generaland was removed in version 12.0.0, so a playbook run against this config errors out with "The 'community.general.yaml' callback plugin has been removed." The built-in option replaces it and produces the same readable output.~/inventory.ini- two groups and a parent group, so host patterns have something to select:[web] ans-a01 [db] ans-b01 [production:children] web db [web:vars] app_port=8080 [all:vars] ansible_python_interpreter=/usr/bin/python3The short names resolve because
/etc/hostson all three machines carries them, and the control node reaches both managed nodes with an ed25519 key and passwordless sudo.If any of this is unfamiliar, build it first: guide 3, guide 4 and guide 5 cover the three pieces in order.
The last two commands are the check worth running before anything else in this path:
pongfrom both hosts, androotwhen escalation is on.bash Example session cat ~/ansible.cfg[defaults]inventory = ./inventory.inihost_key_checking = Falseremote_user = sysadminresult_format = yamlcat ~/inventory.ini[web]ans-a01 [db]ans-b01 [production:children]webdb [web:vars]app_port=8080 [all:vars]ansible_python_interpreter=/usr/bin/python3grep -E "ans-(ctl|a|b)01" /etc/hosts192.168.0.36 ans-ctl01192.168.0.37 ans-a01192.168.0.38 ans-b01ls -l ~/.ssh/id_ed25519 ~/.ssh/id_ed25519.pub-rw------- 1 sysadmin sysadmin 411 Aug 23 07:06 /home/sysadmin/.ssh/id_ed25519-rw-r--r-- 1 sysadmin sysadmin 99 Aug 23 07:06 /home/sysadmin/.ssh/id_ed25519.pubansible-inventory --graph@all: |--@ungrouped: |--@production: | |--@web: | | |--ans-a01 | |--@db: | | |--ans-b01ansible all -m ansible.builtin.pingans-a01 | SUCCESS => { "changed": false, "ping": "pong"}ans-b01 | SUCCESS => { "changed": false, "ping": "pong"}ansible all -m ansible.builtin.command -a "id -un" --becomeans-a01 | CHANGED | rc=0 >>rootans-b01 | CHANGED | rc=0 >>rootExpected resultBoth config files as shown, and
pongplusrootfrom both managed nodes.Success conditionYour control node matches the one every command in this guide was run on.
-
command runs a binary, not a shell
The simple case works:
ans-a01 | CHANGED | rc=0 >> helloA pipe does not:
ans-a01 | FAILED | rc=1 >> error: garbage option Usage: ps [options]Read who complained. That is
psrejecting its arguments, not Ansible rejecting yours.commandsplit the string and ranpswith-ef,|andwcand-las arguments. There is no shell to interpret the pipe, so it was just another word.Redirection goes the same way, and more quietly:
ans-a01 | CHANGED | rc=0 >> redirected > /tmp/cmd-test.txtrc=0, andCHANGED. It succeeded.echoprinted three words, one of which was>. No file was created, nothing reported a problem, and in a playbook this would pass. That is the dangerous one.bash Example session ansible web -m ansible.builtin.command -a "echo hello"ans-a01 | CHANGED | rc=0 >>helloansible web -m ansible.builtin.command -a "ps -ef | wc -l"[ERROR]: Task failed: Module failed: non-zero return codeOrigin: <adhoc 'ansible.builtin.command' task> {'action': 'ansible.builtin.command', 'args': {'_raw_params': 'ps -ef | wc -l'}, 'timeout': 0, 'async_val': 0, [...] ans-a01 | FAILED | rc=1 >>error: garbage option Usage: ps [options] Try 'ps --help <simple|list|output|threads|misc|all>' or 'ps --help <s|l|o|t|m|a>' for additional help text. For more details see ps(1).non-zero return code[exit 2]ansible web -m ansible.builtin.command -a "echo redirected > /tmp/cmd-test.txt"ans-a01 | CHANGED | rc=0 >>redirected > /tmp/cmd-test.txtExpected resultA success, an error from
psitself, and a silent no-op.Success conditionYou can recognise a shell construct that was passed through as text.
-
But it does expand environment variables
Here is where the usual explanation breaks down:
ans-a01 | CHANGED | rc=0 >> /home/sysadmin$HOMEwas expanded, by a module that runs no shell. And yet:ans-a01 | CHANGED | rc=0 >> $RANDOM-$$$RANDOMand$$were not.It is not something upstream doing it -
-vvvshows the module received the string untouched:"_raw_params": "echo $HOME"The module's own documentation names the mechanism:
expand_argument_vars Expands the arguments that are variables, for example `$HOME' will be expanded before being passed to the command to run. If a variable is not matched, it is left unchanged, unlike shell substitution which would remove it.And turning it off proves it:
ans-a01 | CHANGED | rc=0 >> $HOMESo the accurate rule is:
commandexpands environment variables and nothing else.$RANDOMand$$are shell features, not environment variables, so they survive as text - and unlike a shell, an unmatched variable is left in place rather than silently becoming an empty string.bash Example session ansible web -m ansible.builtin.command -a 'echo $HOME'ans-a01 | CHANGED | rc=0 >>/home/sysadminansible web -m ansible.builtin.command -a 'echo $RANDOM-$$'ans-a01 | CHANGED | rc=0 >>$RANDOM-$$ansible web -m ansible.builtin.command -a 'echo $HOME' -vvv 2>&1 | grep -oE '"_raw_params": "[^"]*"' | head -1"_raw_params": "echo $HOME"ansible-doc ansible.builtin.command 2>/dev/null | grep -A5 " expand_argument_vars" expand_argument_vars Expands the arguments that are variables, for example `$HOME' will be expanded before being passed to the command to run. If a variable is not matched, it is left unchanged, unlike shell substitution which would remove it.ansible web -m ansible.builtin.command -a '{"argv": ["echo", "$HOME"], "expand_argument_vars": false}'ans-a01 | CHANGED | rc=0 >>$HOMEExpected resultOne variable expanded, two left alone, and the option that controls it.
Success conditionYou know precisely what reaches the binary.
-
shell gives you all of it
ans-a01 | CHANGED | rc=0 >> 122ans-a01 | CHANGED | rc=0 >> 22664-2402ans-a01 | CHANGED | rc=0 >> redirectedPipe, shell variables, redirection - all working, because there is now a
/bin/shbetween Ansible and the command.The cost is that you have taken responsibility for quoting and for anything the shell might do with your input. That is why the guidance is
commandunless you need a shell feature - not becauseshellis broken, but because a shell will happily interpret a filename containing a semicolon.In practice: reach for a real module first,
commandsecond,shellwhen you genuinely need a pipe or redirection, and treat a playbook full ofshelltasks as a sign that a module was missed.bash Example session ansible web -m ansible.builtin.shell -a "ps -ef | wc -l"ans-a01 | CHANGED | rc=0 >>124ansible web -m ansible.builtin.shell -a 'echo $RANDOM-$$'ans-a01 | CHANGED | rc=0 >>-9345ansible web -m ansible.builtin.shell -a "echo redirected > /tmp/cmd-test.txt && cat /tmp/cmd-test.txt"ans-a01 | CHANGED | rc=0 >>redirectedExpected resultAll three shell features working.
Success conditionYou can choose between the two on the basis of what you need.
-
raw needs no Python at all
Point the interpreter at a path that does not exist and
commanddies:ans-a01 | FAILED! => { "module_stdout": "/bin/sh: 1: /usr/bin/python-does-not-exist: not found\r\n", "msg": "The module interpreter '/usr/bin/python-does-not-exist' was not found.",The same command through
rawworks:ans-a01 | CHANGED | rc=0 >> up 1 hour, 58 minutes Shared connection to ans-a01 closed.That is the entire reason
rawexists. It sends the string straight down the SSH connection with no module, no JSON and no Python. Two jobs only:- Bootstrapping - installing Python on a host that has none.
- Devices that cannot run Python - switches, appliances.
Notice
Shared connection to ans-a01 closed.in the output.rawgives you the raw SSH session, chatter and all, because nothing is parsing it. It also cannot be idempotent, cannot reportchangedhonestly, and does not support--check.bash Example session ansible web -m ansible.builtin.command -a "uptime -p" -e ansible_python_interpreter=/usr/bin/python-does-not-exist 2>&1 | grep -E "FAILED|module_stdout|msg" | head -3ans-a01 | FAILED! => { "module_stdout": "/bin/sh: 1: /usr/bin/python-does-not-exist: not found\r\n", "msg": "The module interpreter '/usr/bin/python-does-not-exist' was not found.",ansible web -m ansible.builtin.raw -a "uptime -p" -e ansible_python_interpreter=/usr/bin/python-does-not-existans-a01 | CHANGED | rc=0 >>up 2 hours, 0 minutesShared connection to ans-a01 closed.Expected resultA module failure and a raw success against a broken interpreter.
Success conditionYou know the one situation that requires
raw. -
Making command idempotent
commandalways reportsCHANGED, because it cannot know what your binary did. Two parameters fix that for the common cases:ans-a01 | CHANGED | rc=0 >>ans-a01 | SUCCESS | rc=0 >> skipped, since /tmp/marker-file existsDid not run command since '/tmp/marker-file' existsSecond run:
SUCCESS, notCHANGED, and the command never ran.creates=tells Ansible 'if this path exists, there is nothing to do'.removes=is the mirror image - only run if the path is there:ans-a01 | SUCCESS | rc=0 >> skipped, since /tmp/marker-file does not existThese two turn a script invocation into something safe to run repeatedly, and they are the standard answer when an exam task says 'run this installer only if it has not already been run'.
The other tool is
changed_when:in a playbook, which lets you decide from the command's own output whether anything actually changed.bash Example session ansible web -m ansible.builtin.command -a "touch /tmp/marker-file creates=/tmp/marker-file"ans-a01 | CHANGED | rc=0 >>ansible web -m ansible.builtin.command -a "touch /tmp/marker-file creates=/tmp/marker-file"ans-a01 | SUCCESS | rc=0 >>skipped, since /tmp/marker-file existsDid not run command since '/tmp/marker-file' existsansible web -m ansible.builtin.command -a "rm /tmp/marker-file removes=/tmp/marker-file"ans-a01 | CHANGED | rc=0 >>ansible web -m ansible.builtin.command -a "rm /tmp/marker-file removes=/tmp/marker-file"ans-a01 | SUCCESS | rc=0 >>skipped, since /tmp/marker-file does not existDid not run command since '/tmp/marker-file' does not existExpected resultEach command running once and skipping the second time.
Success conditionYou can make a raw command safe to run twice.
Troubleshooting
A pipe or redirection appears to do nothing.
Why:
commandhas no shell; the characters were passed as arguments.Fix:Use
shell, or better, find a module that does the job.error: garbage optionor similar from the target binary.Why: Same thing - the binary received shell metacharacters as arguments.
Fix:Read which program produced the error. It is usually not Ansible.
A variable was not expanded the way you expected.
Why:
commandexpands environment variables only.$RANDOM,$$and command substitution are shell features.Fix:Use
shell, or setexpand_argument_vars: falseif you want the literal text.The module interpreter ... was not found.Why: No usable Python on the managed node.
Fix:Use
rawto install one, then setansible_python_interpreter.A command task reports
changedon every run.Why:
commandandshellcannot detect state.Fix:
creates=/removes=, orchanged_when:in a playbook.