What exactly does passing env= to subprocess.run change for the child process?
answer
- It is a swap, not a patch
- Whatever you omit is gone
- The lookup table travels in the mapping
- No PATH means os.defpath
- An allow-list is the security use
basics
~20 sIt replaces the child's environment wholesale instead of adding to it: the child sees exactly the mapping you pass and nothing else. To add one variable, copy os.environ first; to scrub, pass a deliberate allow-list.
solid answer
~40 s`env=` is a replacement, not an update. `subprocess.run([...], env={"TOKEN": "t"})` gives the child a one-entry environment - no `PATH`, no `HOME`, no locale variables, no `PYTHON*` variables. Two things break immediately. Programs that need those variables misbehave, and the executable lookup for a bare program name uses `os.get_exec_path(env)`, so with no `PATH` key the search falls back to `os.defpath` (`/bin:/usr/bin` on POSIX) rather than the parent's `PATH`. The idiom for adding a variable is therefore `child_env = os.environ.copy()` then edit, or `{**os.environ, "TZ": "UTC"}`. Used deliberately the replacement is a security control: an explicit allow-list is how you keep the dynamic loader's preload variable and the `PYTHON*` family out of a child, and it changes only that child rather than mutating the parent's environment.
code
python · 8 linesimport os, subprocess, sys
child_env = os.environ.copy() # env= replaces; it does not merge
child_env["SCHEDULE_TZ"] = "UTC"
subprocess.run([sys.executable, "-c", "pass"], env=child_env, check=True)
print(os.defpath) # fallback used when the mapping has no PATH
print(os.get_exec_path({}))go deeper
Remember that env= gives the child the whole environment, not extra entries. If you only want to add something, start from os.environ.copy() and edit the copy before passing it.
Explain the consequences: which variables vanish, that the bare-name lookup uses the PATH inside the mapping and falls back to os.defpath when there is none, and why copy-then-edit is the standard idiom.
Show the control being used on purpose: an allow-list mapping with a pinned PATH and locale, loader and interpreter variables dropped, applied per call site rather than by mutating the parent, and covered by a test that asserts the child's key set.
Own the standard - one helper in the codebase that builds child environments, so every subprocess launch across services starts from the same reviewed allow-list instead of each team inventing its own scrub.
## Replacement, not merge The `env` parameter of `subprocess.run` and `subprocess.Popen` supplies the complete environment for the new process. Whatever mapping you hand over is what the child gets; anything you leave out is simply absent. Newcomers read `env={"TZ": "UTC"}` as "add `TZ`", and the child then runs with exactly one variable. That single misreading produces most of the "it works in my shell but not from Python" bug reports. To add or override while keeping the rest, build from a copy: ```python child_env = os.environ.copy() # a plain dict child_env["TZ"] = "UTC" subprocess.run([tool, "--diff"], env=child_env, check=True) ``` `{**os.environ, "TZ": "UTC"}` does the same in one expression. Both leave the parent's own environment untouched, which is the point: mutating `os.environ` to influence one child is a process-global side effect that also reaches every other child and every thread. ## What disappearing variables actually break **The executable search.** For a program name with no directory separator, the candidate directories come from `os.get_exec_path(env)`, which reads `PATH` out of the mapping you passed. If your mapping has no `PATH`, the fallback is `os.defpath` - `/bin:/usr/bin` on POSIX - not the parent's `PATH`. So `subprocess.run(["schedhelper"], env={"TOKEN": "t"})` will look only in `/bin` and `/usr/bin` and usually raise `FileNotFoundError`, while `subprocess.run(["echo", "hi"], env={})` quietly succeeds because `/bin/echo` exists. That asymmetry is why the failure is confusing in production and invisible in a smoke test. **Everything the child needs.** Most command-line tools want `HOME` for their configuration, `TMPDIR` for scratch files, and the locale variables for text handling. On Windows the `subprocess` documentation is explicit that a supplied environment must include a valid `SystemRoot` for a side-by-side assembly to load. Keys and values must be strings (bytes are accepted on POSIX), and a name cannot contain a null byte or an `=`. **Output formats.** A flight-schedule differ that parses dates out of a helper's output is at the mercy of the locale variables it forwards: leave `LC_ALL` inherited and the same helper emits a locale-dependent format that your parser accepts on a developer laptop and rejects on a host configured differently. Pinning `LC_ALL="C"` in the child environment is not pedantry, it is how you make the child's output a stable contract. ## Using replacement as a control Because `env=` is total, it is the cleanest way to decide what a child may see. Write an allow-list rather than a deny-list - a deny-list has to enumerate every dangerous name, and the interesting ones are platform-specific: ```python KEEP = ("HOME", "TZ", "LANG") scrubbed = {k: os.environ[k] for k in KEEP if k in os.environ} scrubbed["PATH"] = "/usr/bin:/bin" scrubbed["LC_ALL"] = "C" ``` That single dictionary removes three families at once. The dynamic loader's library-preload variable (`LD_PRELOAD` on Linux, and its equivalents elsewhere) can otherwise make any child load attacker-chosen code before `main`. The proxy variables that many command-line tools honour can otherwise redirect a child's network traffic. And the `PYTHON*` variables matter whenever the child is itself an interpreter, because it reads them at startup out of the environment you handed it - if a variable can steer where a child interpreter looks for modules, the fix is not to hope, it is to not pass it. For a child Python specifically, the `-E` command-line flag makes the child ignore the `PYTHON*` variables outright, and `-I` isolates it further. Use those as belt and braces on top of a scrubbed mapping, not instead of one: the flags only help when the child is CPython, while the mapping governs every child. ## Details worth knowing `env=` affects the child only; `os.environ` in the parent is unchanged, and the child's environment is fixed at exec time - nothing you do afterwards reaches it. It composes with, but is independent of, `cwd=`: `cwd` sets the child's working directory, and because relative entries in `PATH` resolve there, the two parameters interact when your search path is not absolute. And a scrubbed environment is a behaviour change for the child, so it belongs in tests: assert the child sees the exact key set you intended, not merely that the happy path still runs.
- A team passes env={'TOKEN': t} and the call fails with FileNotFoundError. What happened?The mapping has no `PATH`, so the bare program name was searched only in `os.defpath` - `/bin:/usr/bin` on POSIX - and the helper lives somewhere else. It is not that the executable vanished; the search list did. Either add an explicit `PATH` to the mapping, or better, pass the helper's absolute path so no search happens at all.
- Why prefer an allow-list of kept variables over deleting the dangerous ones?Because a deny-list has to be complete, and the set of variables that change a child's behaviour is platform-specific and grows over time - loader variables, proxy variables, locale variables, the interpreter's own family. An allow-list fails closed: a new variable you have never heard of simply does not reach the child, and the call site documents exactly what the child depends on.
- Does passing env= change anything for the parent process?No. The mapping is used only when the child is executed; the parent's `os.environ` and the real environment behind it are untouched, and other children started without `env=` still inherit the parent's environment. That locality is the reason to prefer it over assigning to `os.environ`, which changes the parent and every process it starts from then on.
saying these in an interview costs you the question
- Thinks env= merges with the parent environment
- Expects the parent's PATH to be used when env= omits it
- Passes a scrubbed environment then wonders why HOME-dependent tools break
- Mutates os.environ to configure one child process
- Builds a deny-list of dangerous variables instead of an allow-list
- Assumes a child interpreter ignores PYTHON variables by default