subprocess and os

Updated

September 8, 2026

subprocess and os

The boring way to run another program is subprocess.run with a list of arguments, capture_output=True, and check=True. The interpreter you want is sys.executable. shell=True is not the default. os.environ is how you read the process environment.

Mental model

subprocess.run starts a child, waits, and returns a CompletedProcess. capture_output=True fills stdout and stderr. text=True gives strings instead of bytes. check=True raises CalledProcessError on a non-zero exit.

A list ["echo", "7"] is one program and one argument. A shell string "echo 7; echo pwned" is a tiny program in another language. Do not interpolate desk input into that language.

os.environ is a mapping of environment variables. It is the right place for DESK_SHIFT. It is not a config file.

Worked examples

Case 1: Run this Python, capture stdout

Save as run_label.py. sys.executable is the interpreter that is already running the file (the same one uv run python selected).

# run_label.py
import subprocess
import sys


def main() -> None:
    result = subprocess.run(
        [sys.executable, "-c", "print('ticket 7')"],
        capture_output=True,
        text=True,
        check=True,
    )
    print(result.stdout, end="")


if __name__ == "__main__":
    main()

Run:

uv run python run_label.py

Output:

ticket 7

From a shell you can also write uv run python -c "print('ticket 7')". Inside Python, prefer sys.executable so you do not depend on PATH.

Case 2: check=True surfaces a failed child

Save as run_fail.py. Exit 2 is a failure. Catch CalledProcessError when you intend to handle it.

# run_fail.py
import subprocess
import sys


def main() -> None:
    try:
        subprocess.run(
            [sys.executable, "-c", "raise SystemExit(2)"],
            capture_output=True,
            text=True,
            check=True,
        )
    except subprocess.CalledProcessError as exc:
        print(type(exc).__name__)
        print(exc.returncode)


if __name__ == "__main__":
    main()

Run:

uv run python run_fail.py

Output:

CalledProcessError
2

Without the try, the traceback is the API: the parent fails too.

Case 3: Read the environment

Save as shift_env.py. os.environ.get with a default is the boring lookup. Missing keys are not exceptions unless you want them to be (os.environ["DESK_SHIFT"]).

# shift_env.py
import os


def main() -> None:
    shift = os.environ.get("DESK_SHIFT", "day")
    print(shift)
    print(os.environ.get("NOT_A_DESK_KEY", "missing"))


if __name__ == "__main__":
    main()

Run:

uv run python shift_env.py

Output:

day
missing

If you already exported DESK_SHIFT, that value prints instead of day. Unset it to match this listing.

Case 4: Pass env into the child

Save as child_env.py. env= replaces the child’s environment. Copy os.environ first if the child still needs PATH.

# child_env.py
import os
import subprocess
import sys


def main() -> None:
    env = os.environ.copy()
    env["DESK_SHIFT"] = "night"
    result = subprocess.run(
        [sys.executable, "-c", "import os; print(os.environ['DESK_SHIFT'])"],
        capture_output=True,
        text=True,
        check=True,
        env=env,
    )
    print(result.stdout, end="")


if __name__ == "__main__":
    main()

Run:

uv run python child_env.py

Output:

night

The trap

shell=True plus an interpolated string runs extra commands. A list does not.

Save as shell_trap.py:

# shell_trap.py
import subprocess


def main() -> None:
    ticket = "7; echo pwned"
    unsafe = subprocess.run(
        f"echo {ticket}",
        shell=True,
        capture_output=True,
        text=True,
        check=True,
    )
    safe = subprocess.run(
        ["echo", ticket],
        capture_output=True,
        text=True,
        check=True,
    )
    print("unsafe:", repr(unsafe.stdout))
    print("safe:", repr(safe.stdout))


if __name__ == "__main__":
    main()

Run:

uv run python shell_trap.py

Output:

unsafe: '7\npwned\n'
safe: '7; echo pwned\n'

The “safe” line is one argument that happens to contain a semicolon. The shell never sees it. Default to the list form. If you truly need a shell, you need a written reason.

The boring rule

  • subprocess.run([...], capture_output=True, text=True, check=True).
  • Child Python is sys.executable (or uv run python -c ... from the terminal).
  • Never shell=True as the default. Never format user input into a shell string.
  • Read config-like values from os.environ. Do not mutate os.environ globally if you can pass env= to one child.
  • Files still belong to pathlib. os here is environment and process, not os.path.

Try this

  1. In run_label.py, print result.returncode as well (it should be 0).
  2. In run_fail.py, drop check=True and print returncode without a try.
  3. Run DESK_SHIFT=swing uv run python shift_env.py and confirm the first line changes.