skip to content

How does subprocess.run find the program when args[0] is a bare command name?

level: juniorimportance: should knowfreq 42%

answer

  1. First: does the name contain a separator?
  2. A bare name means a search
  3. Directories tried in order, first match wins
  4. shutil.which does the same from Python

basics

~20 s

With shell=False, a name containing no directory separator is looked up in the PATH directories in order and the first match runs. A name with a separator is a path, resolved against the child's working directory. shutil.which runs the same search.

solid answer

~50 s

If the first item of the argument list contains no directory separator, it is a *name* to be searched for, and CPython builds the candidate list with `os.get_exec_path(env)` - the `PATH` from the `env` you passed, otherwise the one in `os.environ`, otherwise `os.defpath` - then tries each directory in order until an exec succeeds. If the name does contain a separator, no search happens at all: it is a path, and a relative one is resolved against the child's working directory, which is the `cwd` argument if you gave one. Nothing is found means `FileNotFoundError` from the call itself, not a non-zero `returncode`, because the program never started. `shutil.which` runs the same search from Python, returning an absolute path or `None`, and its `path=` argument searches exactly the `PATH` you will hand the child. Resolving up front and passing an absolute path removes the guesswork.

code

python · 6 lines
python
import os
import shutil

print(shutil.which("sh"))                       # absolute path, or None
print(shutil.which("sh", path="/nonexistent"))  # None: searched elsewhere
print(os.get_exec_path())                       # the directories, in order

go deeper

for a junior

Be ready to say what happens when the program does not exist: subprocess.run raises FileNotFoundError rather than returning a failed result, because nothing ever ran. Know that a bare name is looked up in PATH and that shutil.which shows you the answer.

for a middle

Explain where the search list comes from - the PATH in the env you passed, else os.environ, else os.defpath - and why a name containing a separator skips the search and is resolved against the child's working directory instead.

for a senior

Show that you make spawning deterministic: resolve the executable once, log which path you resolved, pass an absolute path together with an explicit env, and diagnose an interactive-versus-service difference by comparing the two search paths rather than by adding directories until it works.

for a principal

Take a position on how binaries are located across a fleet - a controlled PATH from the runner, absolute paths pinned in configuration, or a resolution step at deploy time - and on what should happen when a host has two versions of the same tool installed.

**Name versus path.** The first decision is made on the string itself: does it contain a directory separator? `['picklist-helper', '--wave', '17']` is a *name* and triggers a search. `['./picklist-helper']` or `['/opt/picking/bin/picklist-helper']` is a *path* and triggers none. That single rule explains most confusion in this area, including why adding `./` to a command changes its behaviour completely. **How the search is built.** For a bare name on POSIX, CPython asks `os.get_exec_path(env)` for the list of directories. That function reads `PATH` from the mapping you passed as `env`; if you passed no `env`, it reads it from `os.environ`; if neither has a `PATH` key, it falls back to `os.defpath`, which is `:/bin:/usr/bin` on a typical Unix. The candidates are then tried in order and the first one that execs wins. Two consequences follow immediately: the search honours the environment you are giving the *child*, not the one you happen to be running in; and an empty first element in `os.defpath` means the current directory, which is one more reason not to rely on the fallback. **How a miss is reported.** When no candidate can be executed, the call raises `FileNotFoundError`, and it raises it in the parent - `subprocess.run` never returns a `CompletedProcess` in that case, so `check=True` and `subprocess.CalledProcessError` do not enter into it. Getting an exception rather than an exit status is the tell that the program never ran. A permissions problem surfaces the same way, as `PermissionError`, when a candidate file exists but is not executable. **`shutil.which`.** This is the same search, exposed to Python: ```python path = shutil.which('picklist-helper') if path is None: raise RuntimeError('picklist-helper is not installed on this host') ``` By default it searches the `PATH` in `os.environ`; pass `path=` to search a different one - the same string you are about to put in the child's `env`, ideally, so that what you test is what the child will do. It also takes a `mode` argument, defaulting to a check for existence and the execute bit (`os.F_OK | os.X_OK`), implemented with `os.access`. On Windows it additionally consults `PATHEXT` so that a name without an extension resolves the way the shell would. **Why resolving up front is worth it.** Passing an absolute path to `subprocess.run` makes the spawn independent of the child's `PATH`, which matters most when you are also passing an `env` - the two arguments interact, and a curated `env` that forgot `PATH` breaks the lookup even though the program is installed. Resolving once at startup also gives you a value worth logging: *which* binary you are about to run, on a host that may have three copies of it. **Two honest limits of `shutil.which`.** It answers a question about this instant - the file could be replaced, unlinked or have its mode changed between the check and the exec, so the check is a friendliness feature, not a guarantee, and you still handle `FileNotFoundError` at the call. And the executability check goes through `os.access`, which asks the kernel using the real user and group ids rather than the effective ones and cannot account for every filesystem's access rules, so a `True` is a strong hint rather than a proof. **The shell is a different mechanism.** With `shell=True` you are not passing a program and arguments at all; you are passing a command line to an interpreter which does its own lookup and reports a missing command as an exit status rather than as a Python exception. That is a separate contract, and the reason a team switching a call from `shell=True` to a list of arguments suddenly starts seeing `FileNotFoundError` where they used to see a non-zero return code. **Debugging recipe.** When a command works in your terminal and not from a service, do not guess. Print `os.get_exec_path()` inside the service process, print `shutil.which(name)` there too, and compare with the same two values interactively. The difference between the two `PATH` values is the whole bug, and it is usually that the non-interactive process never read the file where your `PATH` is set.

  • The command works in your terminal but raises FileNotFoundError from a service. Where do you look?
    At the two `PATH` values, not at the program. A service, a scheduler entry or a remote command runs without whatever configuration your interactive session loaded, so its `PATH` is usually shorter. Print `os.get_exec_path()` and `shutil.which(name)` from inside the failing process, compare with the same calls interactively, then fix it in the spawn - resolve to an absolute path, or pass an `env` whose `PATH` you control.
  • What does a successful shutil.which result not guarantee?
    That the exec will work. It reports what exists and looks executable at that moment, so the file can be replaced or unlinked before you spawn, and its executability check goes through `os.access`, which uses the real user and group ids and cannot model every filesystem's rules. Use it for a clear early error message and still handle `FileNotFoundError` and `PermissionError` at the call site.

saying these in an interview costs you the question

  • Thinks PATH is searched even when the name contains a slash
  • Expects a non-zero returncode instead of FileNotFoundError with shell=False
  • Believes shutil.which searches the env passed to the child
  • Assumes the current directory is searched when PATH is set
  • Hardcodes /usr/bin paths instead of resolving the program

context