subprocess & Shell Interop
subprocess runs external binaries from Python with controlled arguments, timeouts, and captured output. Avoid shell=True unless you accept injection risk and quoting complexity.
Recipe
import subprocess
proc = subprocess.run(
["git", "rev-parse", "HEAD"],
check=True,
capture_output=True,
text=True,
timeout=30,
)
print(proc.stdout.strip())When to reach for this:
- Git, rsync, tar, ffmpeg and other mature CLI tools
- Legacy shell scripts you are wrapping incrementally
- Capture stdout for parsing in Python
- Timeout long-running commands to prevent hung cron jobs
Working Example
Run command with timeout, map errors to exit codes, stream large output line by line.
import logging
import subprocess
from collections.abc import Sequence
log = logging.getLogger(__name__)
def run_command(cmd: Sequence[str], timeout: float = 60) -> subprocess.CompletedProcess[str]:
log.info("exec %s", list(cmd))
try:
return subprocess.run(
cmd,
check=True,
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired as exc:
log.error("timeout after %ss: %s", timeout, cmd)
raise
except subprocess.CalledProcessError as exc:
log.error("exit %s stderr=%s", exc.returncode, exc.stderr.strip())
raise
def stream_lines(cmd: Sequence[str]) -> int:
with subprocess.Popen(cmd, stdout=subprocess.PIPE, text=True) as proc:
assert proc.stdout is not None
for line in proc.stdout:
print(line.rstrip())
return proc.wait()
if __name__ == "__main__":
result = run_command(["python3", "--version"])
print(result.stdout)What this demonstrates:
- Sequence of args, not one shell string - no injection surface
check=TrueraisesCalledProcessErrorwith returncode and stderrPopenstreams stdout without buffering entire output in memory
Deep Dive
API Choice
| API | Use |
|---|---|
run() | Most commands - simple sync |
Popen() | Streaming, pipelines, interactive |
check_output() | Quick capture when check=True always |
shell=True Risk
- User input in command string enables shell injection
- Prefer list argv; if shell required, use
shlex.quoteon user segments
Python Notes
import shlex
user_file = "report; rm -rf /"
# BAD: subprocess.run(f"cat {user_file}", shell=True)
subprocess.run(["cat", user_file], check=True)Gotchas
- shell=True with variables - classic injection bug. Fix: argv list or
shlex.spliton trusted scripts only. - No timeout - zombie cron jobs forever. Fix: always set
timeoutproportional to expected runtime. - Capturing huge stdout - memory blowup. Fix: stream with
Popenor redirect to temp file. - Ignoring stderr - hidden failure context. Fix: log
stderron non-zero exit. - Text mode encoding - binary tools break. Fix:
text=Falseand decode explicitly when needed.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Pure Python libs | Operation has mature Python API | Battle-tested CLI already exists |
asyncio.create_subprocess_exec | Async event loop orchestration | Simple sync cron script |
| Fabric/SSH remote | Remote hosts | Local-only command |
FAQs
When is shell=True OK?
Fixed internal scripts with no user input on trusted machines - still document exception and ticket.
How do I pass environment?
env={**os.environ, "FOO": "bar"} - do not dump secrets into child env unnecessarily.
How do I run in specific cwd?
cwd="/path/to/repo" on run() - paths in cmd should be absolute or relative to cwd.
How do I pipeline commands?
Chain two Popen with stdout->stdin pipe, or use shell pipeline with extreme caution and fixed commands.
What exit code means failure?
Non-zero - map known codes in script docs (e.g. 2 usage error).
How do I hide passwords in argv?
Pass via env var or stdin - argv visible in ps on many systems.
Windows differences?
.cmd files may need shell=True on Windows only - isolate platform branches.
How do I test subprocess?
Mock subprocess.run or use known echo binaries in pytest with tmp_path.
Merge stdout/stderr?
stderr=subprocess.STDOUT when single stream simplifies parsing.
How does this relate to SSH?
Local subprocess on remote host via SSH session - see SSH page for remote execution patterns.
Related
- Scripting Basics - CLI entry points
- SSH & Remote Execution - remote commands
- Robust Scripts - error handling
- Parsing Logs & Text - parse command output
- System Scripting Best Practices - safe execution
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+.