AnsibleXLVI · Python and Interpreter DiscoveryRemote Python and interpreter discovery
What actually runs on a managed node
What you'll learn
- State exactly what a managed node must provide for a normal module to run
- List what Ansible does not install or leave behind on a managed node
- Explain why controller Python 3.12+ and managed-node Python are independent requirements
- Predict which of a set of tasks would still work on a host with no Python
- Recognise the failure signature of a missing or unusable target interpreter
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 is agentless” is true and is routinely over-read. It does not mean nothing runs on the managed node. It means nothing runs on the managed node between your runs.
During a run, quite a lot happens there. A module is a Python program.
The controller assembles it, ships it over the transport, and the
managed node executes it with a Python interpreter of its own. The
module prints a JSON document to stdout; the controller reads that
document and turns it into the ok or changed you see on the terminal.
That single sentence is the whole contract, and almost every question in this part is a consequence of it.
Part II established how the payload is built and shipped — AnsiballZ, the temporary directory, pipelining. This lesson is about what the far end has to provide for that to work at all, and what happens to an estate where some hosts cannot provide it.
The contract, stated precisely
For a normal module — apt, service, file, user, copy, and the
several thousand others — the managed node must offer three things.
A transport Ansible can reach it on. Usually SSH, and usually with a POSIX shell available for the account you connect as. The connection plugin owns this; Part II covered the alternatives.
A POSIX shell. Ansible does not execute the module directly. It
executes a shell command line that in turn invokes the interpreter. That
is why the shell plugin (ansible.builtin.sh) exists as a separate
plugin from the connection plugin, and why a host with a locked-down
non-shell login is awkward even when SSH works.
A Python interpreter the module payload can run under. This is the requirement people forget, and it is the subject of this part.
That is the list. Read what is not on it:
- No Ansible installation on the target. The managed node has no idea what Ansible is.
- No daemon, no agent, no listening port that Ansible added.
- No persistent state. The temporary directory holding the payload is removed when the module finishes.
- No matching version. The target does not run the same Python as the controller, and does not need to.
Running the first of those against ansible-core 2.21.3 returns, among
the attributes:
$ ansible-doc ansible.builtin.raw | grep -A4 'platform:' platform:
description: Target OS/families that can be operated against
details: This action is one of the few that requires no Python on the remote as it
passes the command directly into the connection string
platforms: all“One of the few” is doing real work in that sentence. The set is small, and lesson 5 of this part is about how small.
Two Pythons, two jobs
The course has said several times that controller Python and managed-node Python are separate requirements. Here is the reason.
| Controller | Managed node | |
|---|---|---|
| Runs | ansible-playbook, Jinja, the plugin system | one module payload at a time |
| Version required by core 2.21 | 3.12 – 3.14 | 3.9 – 3.14 |
| Enforced by | pip, at install time | nothing, until a module fails |
| Owned by | the automation team | whoever owns the host |
| Changing it | a controller rebuild | a host change window |
The asymmetry in the last three rows is the operationally important part. The controller requirement fails loudly, early, on one machine you control. The managed-node requirement fails quietly, late, on a machine somebody else owns, in the middle of a run.
What the target interpreter has to be able to do
Not much, and that is deliberate. The payload is written to be
importable and runnable by a plain interpreter with the standard
library. It does not import ansible, because ansible is not there.
But “plain interpreter with the standard library” hides two real constraints.
The payload must parse. Module source uses the syntax of the Python
versions upstream still supports. When the managed-node floor rises from
3.8 to 3.9, it rises because module code has started using something 3.8
cannot parse. The failure therefore arrives as a SyntaxError from
inside the payload, not as a polite version check.
Some modules need bindings that are not in the standard library.
This is the one that costs afternoons. ansible.builtin.apt needs the
apt and apt_pkg Python modules, which arrive on Debian and Ubuntu as
the distribution package python3-apt and are installed for one
specific interpreter. ansible.builtin.dnf5 needs the libdnf5
bindings in the same way. SELinux modules need python3-libselinux.
A module that needs bindings is only as portable as the interpreter that has them. That is the mechanism behind the most common interpreter incident in this course, and lesson 3 is about pinning your way out of it.
Which tasks survive without Python
This is worth working through now, because the intuition most people have is wrong.
| Task | Needs Python on target? |
|---|---|
ansible.builtin.package | yes |
ansible.builtin.file | yes |
ansible.builtin.command | yes |
ansible.builtin.shell | yes |
ansible.builtin.setup (fact gathering) | yes |
ansible.builtin.raw | no |
ansible.builtin.script | no |
command and shell are the entries that surprise people. They run a
shell command on the target, so they feel like they should be exempt.
They are not: both are ordinary Python modules that happen to invoke a
subprocess. The payload still has to be shipped and executed by an
interpreter before the subprocess exists.
Only raw and script are genuinely interpreter-free, and both say so
in their own documentation — script describes itself as not requiring
Python on the remote system, “much like the ansible.builtin.raw
module”. script transfers a local file and executes it through the
remote shell; raw passes the command straight into the connection
string.
The failure signature
A host with no usable interpreter does not produce a message that says “install Python”. It produces one of these.
No interpreter found. Discovery tried the candidate list, found
nothing, fell back to /usr/bin/python3, and that is not there either:
[WARNING]: No python interpreters found for host 'edge-07.example.com'
(tried ['python3.14', 'python3.13', 'python3.12', 'python3.11',
'python3.10', 'python3.9', '/usr/bin/python3', 'python3']).
followed by the first module failing with a message about
/usr/bin/python3 not existing.
An interpreter that is too old. The payload arrives and fails to
parse, so you get a SyntaxError with a traceback pointing inside a
temporary path under .ansible/tmp — a filename that does not exist by
the time you go looking for it, because the payload directory is
cleaned up.
An interpreter without the bindings. The module runs and fails with an import error naming a library, most famously:
Failed to import the required Python library (apt) on
web-a3.example.com's Python /usr/bin/python3.13.
Read that message carefully when you meet it: it tells you which interpreter it used. In an estate where discovery is unpinned, that path is the diagnosis.
Blast radius
Reading this lesson as an operator rather than a student, the question is: how many of my hosts could fail this way, and would I find out before or during a change window?
The answer is available without running anything against them, because the interpreter is a gathered fact and a fact cache already holds it if you use one. Where it is not cached, one fact-gathering run answers it for the whole estate:
$ ansible all -m setup -a 'filter=ansible_python_version' --one-lineweb-a1.example.com | SUCCESS => {"ansible_facts": {"ansible_python_version": "3.12.3", ...}, "changed": false}
web-a2.example.com | SUCCESS => {"ansible_facts": {"ansible_python_version": "3.12.3", ...}, "changed": false}
db01.example.com | SUCCESS => {"ansible_facts": {"ansible_python_version": "3.9.21", ...}, "changed": false}
edge-07.example.com | UNREACHABLE! => {"changed": false, "msg": "Failed to connect to the host via ssh", "unreachable": true}Illustrative output
Three supported hosts and one that did not answer. The unreachable host is Part XLIV’s subject, not this one — but note that an unreachable host and a host with no Python look nothing alike, which is genuinely useful: you can tell them apart from the summary line without investigating.
Knowledge check
Knowledge check · 4 questions
Q1. A colleague says Ansible is agentless, so nothing runs on the managed node. What is the accurate correction?
Q2. Which of these still work against a managed node that has no Python interpreter at all? Select all that apply.
Q3. A managed node running Python 3.9 can be managed by a controller running ansible-core 2.21 on Python 3.13.
Q4. A run fails on one host with: Failed to import the required Python library (apt) on that host Python /usr/bin/python3.13. What does the message tell you?
Passing score: 75%. Answers are checked in this browser.