Skip to main content
RunBook Academy

AnsibleVII · Ad-Hoc ExecutionAd-hoc execution

Anatomy of an ad-hoc command

Foundation⏱ ~16 minansible

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

Not yet marked complete on this device.

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.

Read-only / Safethe canonical example
$ ansible web -i inventory.ini -m ansible.builtin.ping
web01.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:

Read-only / Safethe default nobody chooses
$ ansible --help | grep -A1 -- '-m, --module-name'
  -m, --module-name MODULE_NAME
                      Name of the action to execute (default=command)

So this:

Read-only / Safeno -m at all
$ ansible localhost -a "id -un"
localhost | CHANGED | rc=0 >>
opsuser

Illustrative 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.

Read-only / Safekey=value form
$ 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:

Read-only / SafeJSON form
$ 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:

Read-only / Safeno inventory found
$ 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.

Configuration changeescalated ad-hoc
$ 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:

Read-only / Saferesolve the pattern, run nothing
$ 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:

Read-only / Safecapture the result as files
$ ansible web -i inventory.ini -m ansible.builtin.setup -a "filter=ansible_distribution" --tree ./evidence
Read-only / Safewhat it wrote
$ 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:

Read-only / Safedeprecated since 2.21
$ 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:

OutcomeExit code
Success0
Task failure only2
Unreachable host only4
Task failure and unreachable host4

Unreachable wins. A wrapper that tests only for 2 treats a run in which half the fleet could not be contacted as a pass.

Read-only / Safeunreachable is exit 4
$ 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=4

Note 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

  1. Q1. A colleague runs `ansible web -a "systemctl restart nginx"` and tells you it was a read-only check. What actually happened?

  2. 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.

  3. Q3. Which of these flags change how many hosts an ad-hoc command can affect at one moment? Select all that apply.

  4. 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.