subprocess
subprocess spawns external programs, captures stdout/stderr, and manages exit codes. Prefer argument lists without shell unless shell features are explicitly required.
Recipe
import subprocess
result = subprocess.run(
["python", "--version"],
capture_output=True,
text=True,
check=True,
)
print(result.stdout.strip())When to reach for this:
- Git, ffmpeg, openssl CLI wrappers
- Legacy tools without Python API
- Packaging scripts invoking compilers
- Health checks calling curl binary
- Sandboxed isolation via separate process
Working Example
import subprocess
from pathlib import Path
def run_git(args: list[str], cwd: Path) -> str:
completed = subprocess.run(
["git", *args],
cwd=cwd,
capture_output=True,
text=True,
check=False,
)
if completed.returncode != 0:
raise RuntimeError(completed.stderr.strip() or "git failed")
return completed.stdout.strip()
def safe_ls(path: Path) -> str:
# Never shell=True with user input
proc = subprocess.run(
["ls", "-la", str(path)],
capture_output=True,
text=True,
timeout=5,
)
proc.check_returncode()
return proc.stdout
print(run_git(["--version"], Path.cwd()))What this demonstrates:
- List form args avoid shell injection
check_returncodeorcheck=Truefor failurestimeoutprevents hung childrentext=Truedecodes bytes to str with encoding default
Deep Dive
run vs Popen
| API | Use |
|---|---|
run | One-shot wait for completion |
Popen | Streaming IO, background |
IO
capture_output=Truepipes stdout/stderrstdin=subprocess.DEVNULLwhen no input
Gotchas
- shell=True with user input - command injection. Fix: list args,
shlex.quoteif shell required. - Unbounded output - memory exhaustion. Fix: stream with Popen iter or redirect to file.
- Missing timeout - zombie hangs. Fix: always timeout in services.
- CWD trust - relative binaries from wrong dir. Fix: explicit
cwdandenv. - Blocking event loop - subprocess in async route. Fix:
asyncio.create_subprocess_exec.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| shutil.which | Resolve binary path | Need not run |
| asyncio subprocess | Async streaming | Sync script |
| Pure Python lib | Stable API exists | One-off CLI ok |
FAQs
shell=True ever?
Only trusted fixed commands in controlled scripts - never with external input.
Return code 0?
Success convention - check explicitly even if not using check=True.
Merge stderr?
stderr=subprocess.STDOUT into stdout pipe.
Binary output?
Omit text=True; handle bytes and decode explicitly.
Windows?
creationflags for new console; list args still preferred.
Environment?
env={**os.environ, "K": "V"} partial override.
Long running process?
Popen + communicate with timeout or poll loop.
Security audit?
Flag any shell=True and string concatenation in security review.
uv run?
["uv", "run", "python", "-m", "tool"] in list form for reproducible env.
pytest?
Use fixtures with tmp_path; mock subprocess.run for unit tests.
Related
- pathlib & os - cwd paths
- argparse & configparser - CLI tools that wrap subprocess
- Standard Library Best Practices - safety
- Concurrency Basics - blocking vs async
Stack versions: This page was written for Python 3.14.0 (stable 3.14, maintenance 3.13), FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.