AnsibleVII · Ad-Hoc ExecutionAd-hoc execution
Anatomy of an ad-hoc command
What you'll learn
- Decompose an ad-hoc command into pattern, module and module arguments
- Explain why omitting -m silently selects the command module
- Use -i, -b, -f, -e and --tree correctly, and avoid the deprecated -o
- Predict the exit code of an ad-hoc run against an unreachable host
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
An ad-hoc command is a single Ansible task, assembled on the command line, run once, against whatever the pattern matched. There is no file, no review, no diff, and no record beyond your shell history.
That combination makes it the fastest instrument in the toolkit and the one with the least friction between an idea and a fleet. This part is about using the speed and respecting the absence of friction. This first lesson is the mechanics: what each part of the command does, and which parts change how many machines move.
The shape
Every ad-hoc command is the same four pieces:
ansible <pattern> -m <module> -a "<module arguments>"
^^^^^^^^^ ^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^
which what to what to pass
hosts run on them to the module
The pattern is positional and mandatory. Everything else has a default, which is where the surprises live.
$ ansible web -i inventory.ini -m ansible.builtin.pingweb01.example.com | SUCCESS => {
"changed": false,
"ping": "pong"
}
web02.example.com | SUCCESS => {
"changed": false,
"ping": "pong"
}
web03.example.com | SUCCESS => {
"changed": false,
"ping": "pong"
}Illustrative output
The per-host block is the module’s own JSON return value, printed by the
default callback. changed: false and ping: pong came from the module
running on web01, not from anything Ansible inferred. That distinction
is the subject of the next part; hold on to it.
The module you get when you do not choose one
-m defaults to command. Not ping, not shell, and not an error.
The CLI help says so in one line that is easy to read past:
$ ansible --help | grep -A1 -- '-m, --module-name' -m, --module-name MODULE_NAME
Name of the action to execute (default=command)So this:
$ ansible localhost -a "id -un"localhost | CHANGED | rc=0 >>
opsuserIllustrative output
is a command module invocation. Two things in that output deserve
attention.
First, CHANGED. The command read a username and altered nothing, and
Ansible reported a change anyway — because command has no way to know
whether the thing it ran changed anything, so it claims a change on every
successful run. The severity badge on that block says READ-ONLY because
id is read-only; Ansible’s own report disagrees, and Ansible is wrong.
Second, rc=0. That is the exit status of the process on the target,
surfaced as a return key. It is the module reporting, not Ansible
observing.
Module arguments
-a takes either space-separated key=value pairs or a JSON object.
$ ansible localhost -m ansible.builtin.setup -a "filter=ansible_distribution*"localhost | SUCCESS => {
"ansible_facts": {
"ansible_distribution": "Ubuntu",
"ansible_distribution_file_parsed": true,
"ansible_distribution_file_path": "/etc/os-release",
"ansible_distribution_file_variety": "Debian",
"ansible_distribution_major_version": "24",
"ansible_distribution_release": "noble",
"ansible_distribution_version": "24.04"
},
"changed": false
}Illustrative output
The JSON form exists because key=value cannot express a list, a nested
dictionary, or a value containing spaces:
$ ansible localhost -m ansible.builtin.setup -a '{"filter": ["ansible_kernel", "ansible_memtotal_mb"]}'localhost | SUCCESS => {
"ansible_facts": {
"ansible_kernel": "6.8.0-51-generic",
"ansible_memtotal_mb": 15990
},
"changed": false
}Illustrative output
A few modules — command, shell, raw, script — accept a free
form argument instead, which is why -a "uptime" works with no key=
prefix at all. ansible-doc marks these with a free_form pseudo-option
that says, in as many words, “there is no actual parameter named
free_form”.
The flags that matter, in order of how much damage they can do
-i — which inventory
Without -i, Ansible reads the inventory named by the config in effect,
falling back to /etc/ansible/hosts. If neither exists you get an
implicit localhost and a warning:
$ ansible localhost -m ansible.builtin.ping[WARNING]: No inventory was parsed, only implicit localhost is available
localhost | SUCCESS => {
"changed": false,
"ping": "pong"
}-i also accepts a literal host list if you end it with a comma, which
is how you target one machine that is not in any inventory:
ansible all -i web01.example.com, -m ansible.builtin.ping
The trailing comma is what distinguishes “this is a list of hosts” from “this is a filename”. Leaving it off produces a confusing failure to open a file named after your host.
-f — how many at once
-f sets forks: the number of hosts Ansible works on in parallel. The
default is 5.
Treat that as a safety parameter, not a performance one. With -f 5, a
mistake against 200 hosts damages five at a time and you have some
seconds to hit Ctrl-C. With -f 200, it damages all of them before your
hand reaches the keyboard. The performance argument for raising forks is
real and is covered where tuning belongs; the point here is that the flag
has a second meaning.
-b — become
-b runs the module with privilege escalation, sudo by default,
becoming root by default. It changes nothing about targeting and
everything about consequences.
$ ansible web -i inventory.ini -b -m ansible.builtin.package -a "name=htop state=present"That block carries a CONFIGURATION badge and it is the first one in this lesson that is not READ-ONLY. Notice how little the command line changed.
-e — extra variables
-e sets variables at the highest precedence Ansible has. In an ad-hoc
command this is mostly used to override a connection detail:
ansible web -i inventory.ini -m ansible.builtin.ping -e ansible_python_interpreter=/usr/bin/python3.12
The precedence part is a topic of its own. What matters here is that -e
beats everything in your inventory, so it is also the fastest way to make
a run behave differently from every other run of the same command.
--list-hosts — the free one
--list-hosts resolves the pattern and prints the result without
executing anything:
$ ansible -i inventory.ini 'production:!db' --list-hosts hosts (3):
web01.example.com
web02.example.com
web03.example.com-C and -D — check and diff
-C (--check) asks the module to predict rather than act, and -D
(--diff) asks it to show what would differ. Both are only as good as
the module: some support check mode fully, some partially, and some not
at all, in which case the task is skipped and reports nothing. That is a
per-module property with its own lesson in the next part.
--tree — the one that leaves evidence
--tree <dir> writes each host’s JSON result to a file named after the
host:
$ ansible web -i inventory.ini -m ansible.builtin.setup -a "filter=ansible_distribution" --tree ./evidence$ cat ./evidence/web01.example.com{"ansible_facts": {"ansible_distribution": "Ubuntu"}, "changed": false}Illustrative output
Remember this flag. Lesson 5 of this part is about the fact that an
ad-hoc command normally leaves nothing behind, and --tree is one of the
two mechanisms that changes that.
-o — do not use it
-o condenses each host’s result onto one line. It was the standard way
to make a fleet-wide inspection readable, and in ansible-core 2.21 it
is deprecated:
$ ansible localhost -m ansible.builtin.ping -o[DEPRECATION WARNING]: The '-o' argument is deprecated. This feature will be removed from ansible-core version 2.23. Use callback configuration to enable the oneline callback instead.
[DEPRECATION WARNING]: oneline has been deprecated. Use another callback plugin, or vendor and/or move the oneline callback to a collection. This feature will be removed from ansible-core version 2.23.
localhost | SUCCESS => {"changed": false,"ping": "pong"}Read the second warning carefully: the oneline callback is deprecated
too, so “use callback configuration instead” buys you two releases, not a
permanent answer. If you need condensed fleet output that will still work
in 2.23, write the results out with --tree and process the JSON, or use
the minimal callback. Do not build a monitoring script on -o today.
Exit codes
An ad-hoc run sets an exit code, and CI systems and shell scripts read it. The values are not what most people assume:
| Outcome | Exit code |
|---|---|
| Success | 0 |
| Task failure only | 2 |
| Unreachable host only | 4 |
| Task failure and unreachable host | 4 |
Unreachable wins. A wrapper that tests only for 2 treats a run in which
half the fleet could not be contacted as a pass.
$ ansible cache -i inventory.ini -m ansible.builtin.ping; echo "exit=$?"cache01.example.com | UNREACHABLE! => {
"changed": false,
"msg": "Task failed: Failed to connect to the host via ssh: ssh: connect to host 203.0.113.31 port 22: Connection timed out",
"unreachable": true
}
exit=4Note also what the failure is not: there is no PLAY RECAP line in
ad-hoc output. The recap is a playbook artefact. With ad-hoc you get one
block per host and an exit code, and if you piped the output to grep
you have thrown away the only summary there was.
Knowledge check
Knowledge check · 4 questions
Q1. A colleague runs `ansible web -a "systemctl restart nginx"` and tells you it was a read-only check. What actually happened?
Q2. An ad-hoc command that finishes with no unreachable hosts but two failed tasks exits with code 2, while the same run with one unreachable host exits with code 4 regardless of how many tasks failed.
Q3. Which of these flags change how many hosts an ad-hoc command can affect at one moment? Select all that apply.
Q4. You need to record what an ad-hoc inspection returned, as evidence for an incident review. Which option does that?
Passing score: 75%. Answers are checked in this browser.