skip to content

Modules and Plugins

Modules do the actual work — file, template, package, service — and their contract is to report changed only when they changed something. Interviewers ask why command and shell break that contract and what you do about it.

on this pageshow

questions

5

What is the difference between Ansible's command and shell modules, and which should you reach for by default?

level: juniorimportance: must knowfreq 66%

answer

  1. is there a shell in between?
  2. pipes and redirects need one of them
  3. metacharacters become literal arguments
  4. templated variables and injection
  5. argv avoids string parsing entirely

basics

~20 s

Ansible's command module executes a program directly with no shell, so pipes, redirects, globs and environment-variable expansion do not work. The shell module runs the command line through a shell on the target, so they do. Prefer command unless you need shell features.

solid answer

~50 s

`ansible.builtin.command` passes the program and its arguments straight to the operating system without a shell in between, so shell metacharacters — `|`, `>`, `&&`, `;`, `*`, `$HOME` — are handed to the program as literal text rather than interpreted. `ansible.builtin.shell` wraps the same command line in a shell on the managed host (`/bin/sh` by default, overridable with the `executable` argument), so all of that works. The default choice is `command`, for two reasons: there is no shell to misinterpret a variable that happens to contain a space or a semicolon, which removes a whole class of injection bugs when the command line is templated; and it makes the dependency on shell behaviour explicit when you do switch. Reach for `shell` deliberately, when you actually need a pipeline, a redirect, or expansion. Both share `chdir`, `creates` and `removes`, and neither is idempotent on its own.

code

yaml · 13 lines
yaml
- name: Restore a dump, needs a shell for the redirect
  ansible.builtin.shell:
    cmd: set -o pipefail && gunzip -c /tmp/db.sql.gz | psql app
    executable: /bin/bash
    creates: /var/lib/app/.restored

- name: Run a vendor installer with no shell involved
  ansible.builtin.command:
    argv:
      - /opt/vendor/install
      - --config
      - /etc/vendor/{{ env_name }}.conf
    creates: /opt/vendor/.installed

go deeper

for a junior

Be able to say plainly that command runs the program with no shell while shell runs it through one, and that pipes, redirects and globs therefore only work with shell. Name command as the default.

for a middle

Explain the practical consequences: templated variables become shell syntax under shell, argv removes string parsing altogether, and /bin/sh may be dash rather than bash on the target.

for a senior

Show review judgment — most shell tasks in a real repository should be a purpose-built module. Be ready to discuss pipefail, exit status of a pipeline, and raw as the pre-Python bootstrap escape hatch.

for a principal

Own the convention across the estate: what is allowed to shell out at all, how such tasks are reviewed and guarded, and when a repeated shell-out becomes a supported module instead of copy-paste in every role.

## Two modules, one difference Both modules run a command on the managed host and return its exit status, `stdout` and `stderr`. The single difference is whether a shell sits between Ansible and the program. `ansible.builtin.command` splits the command line into an argument vector and executes the program directly. Nothing interprets metacharacters. `ansible.builtin.shell` hands the whole string to a shell on the target — `/bin/sh` unless you set the `executable` argument — and lets the shell do its usual job: word splitting, glob expansion, variable expansion, pipelines, redirection, command chaining with `&&` and `;`. ```yaml # Fails: the > and the pipe are passed to `cat` as literal arguments - ansible.builtin.command: cat /etc/hosts | grep db > /tmp/db_hosts # Works: the shell parses the pipeline and the redirect - ansible.builtin.shell: cat /etc/hosts | grep db > /tmp/db_hosts ``` The classic interview symptom is a task that "does nothing but reports success": someone wrote a redirect under `command`, the program accepted the extra arguments or ignored them, and the output file never appeared. ## Why command is the default **Injection surface.** Task command lines are usually templated from variables. Under `shell`, a variable containing `; rm -rf /var/lib/app` or even an unexpected space becomes shell syntax. Under `command`, it is one literal argument. When the value comes from inventory, from a `register`ed result, or from a survey field, that difference is the difference between an argument and an incident. For full control you can use `command` with `argv` — an explicit list of arguments, with no string parsing at all. **Explicitness.** Choosing `shell` documents "this task depends on shell features". A codebase where everything is `shell` tells the reader nothing. **Portability.** `/bin/sh` is not the same shell everywhere. Bash-isms written under `shell` on a Debian control host can behave differently on a target where `/bin/sh` is dash. If you need bash specifically, say so with `executable: /bin/bash` rather than assuming. ## What they share Both accept `chdir` (run from a directory), `creates` (skip if a path exists) and `removes` (skip if a path does not exist), and both are non-idempotent by nature — Ansible cannot inspect what your command did, so a successful run reports changed. Both also skip by default under `--check`. Both also require Python on the managed host, because they are ordinary modules shipped to the target and executed there. When the host has no Python — a bare container, a network appliance, a machine you are about to bootstrap — `ansible.builtin.raw` is the escape hatch: it pushes the command straight down the SSH connection with no module payload at all. Use it to install Python, then stop using it. ## When you should be using neither The most common right answer to "command or shell?" in a review is "neither". Reaching for a shell is usually a sign that a purpose-built module exists and was not looked for: `ansible.builtin.uri` for HTTP calls, `ansible.builtin.git` for clones and checkouts, `ansible.builtin.unarchive` for tarballs, `ansible.builtin.file` for permissions and symlinks, `ansible.builtin.service` for daemons, `ansible.builtin.package` for installs. Those modules compare state and report changed honestly; a shell-out cannot. When you genuinely must shell out — a vendor installer, a one-off migration, a CLI with no module — the reviewable form is: `command` rather than `shell` unless a shell feature is required, a `creates` guard so it runs once, and a clear task `name` explaining what the command is for. ## A small trap worth naming Running a long pipeline under `shell` reports the exit status of the *last* command in the pipeline, which is standard shell behaviour and means a failing first stage can pass silently. If that matters, set `executable: /bin/bash` and include `set -o pipefail` at the front of the command, or split the work into separate tasks. This is the sort of detail that separates "I have used shell" from "I have debugged shell in a playbook".

  • A task using ansible.builtin.command reports ok but the output file it should have written is missing. What happened?
    The command line almost certainly contains a redirect. `command` runs the program directly, so `>` and the filename are passed to the program as ordinary arguments rather than opening a file. Many programs ignore or tolerate the extra arguments and exit zero, so Ansible reports success. The fix is to switch that task to `shell`, or better, to write the file with a module such as `copy` or `template`.
  • When would you use ansible.builtin.raw instead of either module?
    When the managed host has no usable Python interpreter — a freshly booted minimal image, a stripped container, or a network device. Ordinary modules are Python payloads shipped to the target, so they cannot run there. `raw` sends the command straight over the connection with no payload. The normal use is a single bootstrap task that installs Python, after which everything else uses real modules.
  • How do you make a command module task run something under bash specifically?
    You cannot — `command` deliberately involves no shell. If you need bash, use `ansible.builtin.shell` with `executable: /bin/bash`, which is also the way to get bash-only constructs such as `set -o pipefail` or process substitution. Relying on `/bin/sh` being bash is a portability bug: on Debian and Ubuntu it is dash.

saying these in an interview costs you the question

  • Says shell is just a newer version of command
  • Uses a redirect or pipe under the command module
  • Believes command is idempotent and shell is not
  • Interpolates untrusted variables into a shell command line
  • Assumes /bin/sh is bash on every target

context

open as a page

In Ansible, what does a module reporting "changed" actually mean, and why do the command and shell modules report changed on every run?

level: middleimportance: must knowfreq 72%

basics

~20 s

Changed means the module actually altered the host during this run. The command and shell modules run an opaque command and cannot tell whether anything changed, so they report changed every time unless you add creates, removes, or changed_when.

open as a page

In Ansible, what is the difference between a module and a plugin, and where does each one actually execute?

level: middleimportance: should knowfreq 45%

basics

~20 s

Modules are units of work shipped to the managed host and executed there by its Python interpreter, returning JSON. Plugins extend the control node itself — lookup, connection, callback, inventory, strategy, become — and run locally, never on the target.

open as a page

You are writing a custom Ansible module in Python. What contract must it satisfy to behave like a first-class module?

level: seniorimportance: should knowfreq 32%

basics

~20 s

A custom Ansible module must declare its inputs through an argument_spec on AnsibleModule, compare current state with desired state, act only when they differ, honour check mode, and exit by printing JSON via exit_json with an accurate changed flag or fail_json with a message.

open as a page

A CLI-driven operation is repeated across many of your Ansible roles as a shell task. How do you decide whether to leave it, wrap it in a role, or invest in a custom module?

level: principalimportance: nice to knowfreq 22%

basics

~20 s

Decide on repetition, idempotency and blast radius. Search for an existing module in a maintained collection first; wrap in a role when the shell-out is rare and guardable; write a module when the operation is widespread, needs honest changed reporting and check mode, and someone will own it.

open as a page