cheat sheet

subprocess

Run external commands from Python with subprocess. Covers run vs Popen, capture_output, streaming, pipes, timeouts, env/cwd overrides, and shlex quoting safety.

#python#stdlib#shellupdated 05-25-2026

subprocess — Spawning Processes

What it is

subprocess is Python's standard library for spawning new processes, sending data through their stdin, and reading their stdout, stderr, and exit code. It has been the recommended approach since Python 3.5 (PEP 324, refined by PEP 446), superseding the older os.system, os.popen, and the commands module. Reach for subprocess whenever you need to shell out to another binary (git, ffmpeg, aws, psql) from a Python script — and reach for subprocess.run first, dropping down to Popen only when you need streaming I/O or finer control.

Install

subprocess is part of the Python standard library and requires no installation. Verify it loads:

bash
python -c "import subprocess; print(subprocess.run(['echo', 'ok'], capture_output=True, text=True).stdout)"

Output:

text
ok

Mental model

A subprocess.run call wraps the entire lifecycle of one external command: fork-exec a child, optionally feed it stdin, wait for it to exit, and return a CompletedProcess with returncode, stdout, and stderr. Popen is the lower-level object — run is built on top of it — and is what you use when you need to communicate while the child is still running.

python
import subprocess

result = subprocess.run(
    ["ls", "-1", "/home/alice"],
    capture_output=True,
    text=True,
    check=True,
)
print("returncode:", result.returncode)
print("stdout:", result.stdout.strip())

Output:

text
returncode: 0
stdout: Documents
Downloads
projects

subprocess.run — the preferred entry point

subprocess.run(args, ...) runs args to completion and returns a CompletedProcess. It blocks until the child exits, handles cleanup automatically, and is documented as the right call for "the majority of use cases." Pass args as a list of strings, not a single string — that's the safe form that avoids the shell.

python
import subprocess

cp = subprocess.run(["git", "status", "--short"], capture_output=True, text=True)
print(cp.returncode)
print(cp.stdout)

Output:

text
0
 M README.md
?? notes.txt

Essential keyword arguments

subprocess.run accepts a few dozen keyword arguments but most calls use the same handful. The table below covers everything you'll reach for day-to-day.

KeywordTypeEffect
argslist[str] or strCommand + args. Use a list (no shell).
capture_outputboolShortcut for stdout=PIPE, stderr=PIPE.
stdout, stderr, stdinfile / PIPE / DEVNULLRedirect each stream.
textboolDecode stdout/stderr as text (UTF-8 by default). Same as universal_newlines=True.
encoding / errorsstrChoose text encoding and error handler (e.g. "replace").
checkboolRaise CalledProcessError if exit code is non-zero.
timeoutfloat (seconds)Kill the child after this many seconds and raise TimeoutExpired.
cwdpathWorking directory for the child.
envdict[str, str]Replace (not extend) the child's environment.
inputstr or bytesData to send to the child's stdin then close it.
shellboolRun via /bin/sh -c (or cmd.exe /c). Avoid.

capture_output=True, text=True, check=True

The single most useful triple. Together they say: "run this command, give me its output as a string, and raise if it failed." Use them as the default for any command whose output you care about.

python
import subprocess

cp = subprocess.run(
    ["uname", "-a"],
    capture_output=True,
    text=True,
    check=True,
)
print(cp.stdout.strip())

Output:

text
Linux myhost 6.5.0-15-generic #15-Ubuntu SMP Tue Jan  9 19:11:25 UTC 2026 x86_64 GNU/Linux

Exit codes and CalledProcessError

check=True raises CalledProcessError on non-zero exit. The exception carries returncode, cmd, stdout, stderr, making it ideal for "fail fast" pipelines. Without check=True, inspect cp.returncode yourself.

python
import subprocess

try:
    subprocess.run(["ls", "/no/such/path"], capture_output=True, text=True, check=True)
except subprocess.CalledProcessError as e:
    print("rc:", e.returncode)
    print("cmd:", e.cmd)
    print("stderr:", e.stderr.strip())

Output:

text
rc: 2
cmd: ['ls', '/no/such/path']
stderr: ls: cannot access '/no/such/path': No such file or directory

Passing input via stdin

input= writes a string (or bytes) to the child's stdin, then closes the stream. This is how you feed a heredoc-style payload to a command without temp files. Combine with capture_output=True, text=True to round-trip text.

python
import subprocess

cp = subprocess.run(
    ["wc", "-w"],
    input="alice bob charlie\nalice charlie\n",
    capture_output=True,
    text=True,
    check=True,
)
print("word count:", cp.stdout.strip())

Output:

text
word count: 5

timeout= and process killing

timeout= kills the child after N seconds and raises TimeoutExpired. The exception carries any output captured before the kill. Always wrap network-bound commands in a timeout — there's no other way to bound their runtime.

python
import subprocess

try:
    subprocess.run(["sleep", "10"], timeout=1, capture_output=True, text=True)
except subprocess.TimeoutExpired as e:
    print(f"timed out after {e.timeout}s; killed {e.cmd}")

Output:

text
timed out after 1s; killed ['sleep', '10']

Overriding cwd and env

cwd= sets the child's working directory; env= replaces (not extends) the child's environment. To extend, copy os.environ first. Both are essential when scripting against git (which is cwd-sensitive) or when injecting credentials without leaking them into your own process.

python
import os
import subprocess

env = os.environ.copy()
env["GIT_PAGER"] = "cat"             # disable pager
env["LANG"] = "C"                    # force English error messages
env["GIT_AUTHOR_NAME"] = "Alice Dev"  # only for this child

cp = subprocess.run(
    ["git", "log", "-1", "--format=%an %ae"],
    cwd="/home/alice/projects/demo",
    env=env,
    capture_output=True,
    text=True,
    check=True,
)
print(cp.stdout.strip())

Output:

text
Alice Dev alice@example.com

env={} clears the child's environment entirely — including PATH. The child won't find executables by name. If you only need to add a variable, copy os.environ first.

shell=True and why to avoid it

When shell=True, the first argument is passed verbatim to /bin/sh -c (or cmd.exe /c). This enables shell features (globbing, pipes, redirection, environment expansion) but opens you to command injection if any part of the string comes from user input. The default shell=False exec's the binary directly and is safe by construction.

python
import subprocess

# DON'T (vulnerable if `user_arg` came from outside):
subprocess.run(f"ls {user_arg}", shell=True)

# DO:
subprocess.run(["ls", user_arg])

Output: (none — security note)

Never interpolate untrusted strings into a shell=True command. Use a list of args, or escape with shlex.quote if you absolutely must.

shlex.quote for safe shell strings

When you genuinely need a single shell command string (logging it, writing a .sh file, building an SSH command line), shlex.quote wraps each argument in single quotes so the shell sees it as one literal token. shlex.join does the whole list at once.

python
import shlex
import subprocess

paths = ["/home/alice/My Docs", "/tmp/notes; rm -rf /"]
safe = shlex.join(["ls", "-la", *paths])
print(safe)

# Pipe through `ssh` to run on a remote host
subprocess.run(["ssh", "alicedev@myhost.local", safe], check=True)

Output:

text
ls -la '/home/alice/My Docs' '/tmp/notes; rm -rf /'

Popen — the lower-level API

Popen constructs the child process and returns immediately, without waiting. You can write to proc.stdin, read from proc.stdout / proc.stderr, and call proc.wait() / proc.communicate() / proc.terminate() / proc.kill() as needed. Use it when you need streaming, multiple concurrent children, or pipelines.

python
import subprocess

proc = subprocess.Popen(
    ["ping", "-c", "3", "127.0.0.1"],
    stdout=subprocess.PIPE,
    stderr=subprocess.STDOUT,
    text=True,
)
stdout, _ = proc.communicate(timeout=10)
print("returncode:", proc.returncode)
print(stdout.splitlines()[0])

Output:

text
returncode: 0
PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data.

Streaming stdout line by line

For long-running commands, you want to react to output as it arrives, not after the child exits. Read proc.stdout like a file: each iteration yields the next line. This is the right way to tail a tail -f, docker logs, or npm run build.

python
import subprocess

with subprocess.Popen(
    ["ping", "-c", "4", "8.8.8.8"],
    stdout=subprocess.PIPE,
    stderr=subprocess.STDOUT,
    text=True,
    bufsize=1,                # line-buffered
) as proc:
    for line in proc.stdout:
        print(">", line.rstrip())
    if proc.wait() != 0:
        raise subprocess.CalledProcessError(proc.returncode, proc.args)

Output:

text
> PING 8.8.8.8 (8.8.8.8) 56(84) bytes of data.
> 64 bytes from 8.8.8.8: icmp_seq=1 ttl=117 time=8.42 ms
> 64 bytes from 8.8.8.8: icmp_seq=2 ttl=117 time=7.91 ms
> 64 bytes from 8.8.8.8: icmp_seq=3 ttl=117 time=8.15 ms
> 64 bytes from 8.8.8.8: icmp_seq=4 ttl=117 time=8.03 ms

Always wrap Popen in a with block. The context manager closes pipes and waits for the child, preventing zombies and file-descriptor leaks.

Piping one process into another

To replicate ps aux | grep python, wire the stdout of the first Popen into the stdin of the second. Close the upstream stdout after handing it off so the downstream sees EOF when the source finishes.

python
import subprocess

ps = subprocess.Popen(["ps", "aux"], stdout=subprocess.PIPE)
grep = subprocess.Popen(
    ["grep", "python"],
    stdin=ps.stdout,
    stdout=subprocess.PIPE,
    text=True,
)
ps.stdout.close()                    # let grep see EOF
out, _ = grep.communicate(timeout=5)
print(out.splitlines()[0])

Output:

text
alicedev 12345  0.1  0.4  74132 33872 ?  Sl  14:22  0:00 python /home/alice/app.py

Pipelines without two Popens

If you don't need the streaming, the simpler approach is to feed the first command's captured stdout into the second via input=. Two blocking run() calls — easier to read and debug.

python
import subprocess

ps = subprocess.run(["ps", "aux"], capture_output=True, text=True, check=True)
grep = subprocess.run(
    ["grep", "python"],
    input=ps.stdout,
    capture_output=True,
    text=True,
)
print(grep.stdout.splitlines()[0])

Output:

text
alicedev 12345  0.1  0.4  74132 33872 ?  Sl  14:22  0:00 python /home/alice/app.py

Redirecting to a file

stdout= and stderr= accept any file object (or file descriptor int). Open a file in write mode, pass the handle, and the child writes directly to disk without Python buffering its output. Combine with subprocess.STDOUT to merge stderr into stdout.

python
import subprocess
from pathlib import Path

log = Path("/home/alice/logs/build.log")
log.parent.mkdir(parents=True, exist_ok=True)
with log.open("w", encoding="utf-8") as f:
    subprocess.run(
        ["pip", "install", "-r", "requirements.txt"],
        stdout=f,
        stderr=subprocess.STDOUT,
        check=True,
    )
print("wrote", log)

Output:

text
wrote /home/alice/logs/build.log

Discarding output

Set stdout=subprocess.DEVNULL (and/or stderr=DEVNULL) to throw away output cleanly. Faster and simpler than > /dev/null redirection through a shell.

python
import subprocess

# Run a noisy command and ignore everything but the exit code
rc = subprocess.run(
    ["pre-commit", "run", "--all-files"],
    stdout=subprocess.DEVNULL,
    stderr=subprocess.DEVNULL,
).returncode
print("hooks pass" if rc == 0 else f"hooks fail (rc={rc})")

Output:

text
hooks pass

Sending signals

proc.terminate() sends SIGTERM (graceful stop). proc.kill() sends SIGKILL (immediate). proc.send_signal(sig) sends any signal. Always give the process a chance to clean up before escalating.

python
import signal
import subprocess
import time

proc = subprocess.Popen(["sleep", "30"])
time.sleep(1)
proc.send_signal(signal.SIGTERM)
try:
    proc.wait(timeout=2)
except subprocess.TimeoutExpired:
    proc.kill()
    proc.wait()
print("returncode:", proc.returncode)

Output:

text
returncode: -15

A negative returncode means the child was terminated by signal N (-15 = killed by SIGTERM). On Windows, terminate() and kill() both call TerminateProcess and the returncode is 1.

Handling Ctrl-C cleanly

When the user hits Ctrl-C, both your Python process and any child sharing the same terminal receive SIGINT. Wrap your Popen in a try/except KeyboardInterrupt to kill the child and re-raise. Without this, the child keeps running after your script exits.

python
import subprocess

try:
    with subprocess.Popen(["ffmpeg", "-i", "in.mp4", "out.mkv"]) as proc:
        proc.wait()
except KeyboardInterrupt:
    proc.terminate()
    try:
        proc.wait(timeout=5)
    except subprocess.TimeoutExpired:
        proc.kill()
    print("\nffmpeg interrupted; cleaned up")

Output:

text
^C
ffmpeg interrupted; cleaned up

Async subprocesses with asyncio

asyncio.create_subprocess_exec is the async cousin of Popen — fire dozens of commands concurrently without blocking the event loop. Use it inside async code (FastAPI handlers, async CLIs) where blocking subprocess.run would stall the whole server.

python
import asyncio

async def run(*args: str) -> tuple[int, str]:
    proc = await asyncio.create_subprocess_exec(
        *args,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.STDOUT,
    )
    out, _ = await proc.communicate()
    return proc.returncode, out.decode()

async def main():
    rcs = await asyncio.gather(
        run("git", "rev-parse", "HEAD"),
        run("git", "branch", "--show-current"),
    )
    for rc, out in rcs:
        print(rc, out.strip())

asyncio.run(main())

Output:

text
0 7c4f3a2b9d1e5f6c8a0b2d4e6f8a0c2e4f6a8c0e
0 main

Windows note — cmd.exe vs PowerShell

On Windows, subprocess calls CreateProcess directly. Built-in shell commands (dir, copy, echo) are not exe files — they only exist inside cmd.exe. To use them you need shell=True (which spawns cmd.exe). For PowerShell, invoke powershell.exe -NoProfile -Command ... or pwsh -Command ... directly.

python
import subprocess

# WRONG on Windows — `dir` is not an exe
# subprocess.run(["dir"])  # FileNotFoundError

# RIGHT: use a real exe
subprocess.run(["powershell", "-NoProfile", "-Command", "Get-ChildItem"], check=True)

Output: (none — PowerShell prints directory listing)

subprocess vs alternatives

For most tasks subprocess.run is the right answer. Where Python alone can do the job — file listing, regex matching, JSON parsing — prefer pure Python; spawning processes is 10–100× slower than the in-process call.

TaskUse
Spawn one command, wait for itsubprocess.run
Stream output while command runssubprocess.Popen
Async / concurrent subprocessesasyncio.create_subprocess_exec
Just need the file listpathlib.Path.glob (not subprocess.run(["ls"]))
Just need to read a filePath.read_text() (not cat)
Just need an HTTP requesthttpx (not curl)
Heavy shell pipelinessh (third-party), plumbum, or a real shell script
Run as a different usersubprocess.run(..., user=, group=) (3.9+)

Common pitfalls

  1. Passing a single string with shell=False raises FileNotFoundError. ["ls -la"] looks for an executable literally named ls -la. Use ["ls", "-la"] or set shell=True.
  2. shell=True with untrusted input is a remote-code-execution bug. Use a list of args or shlex.quote.
  3. env={} clears PATH. Copy os.environ first.
  4. Forgetting text=True gives you bytes. Decoding manually with .decode() works but text=True is cleaner and lets you pick encoding= and errors="replace".
  5. Popen without with leaks pipes and creates zombies. Always use a context manager.
  6. Deadlock on large output — using proc.stdout.read() while the child also fills stderr blocks both sides forever. Use communicate(), or redirect one stream to DEVNULL/STDOUT.
  7. Timeout without kill() + wait()TimeoutExpired is raised but the child may still be alive. Always proc.kill(); proc.wait() in the except block.
  8. Globbing in args doesn't work: ["ls", "*.txt"] looks for a literal *.txt. Either glob in Python (Path.glob) or use shell=True deliberately.
  9. Reading line-by-line without bufsize=1 can hang because the child's stdout is fully buffered when not a TTY. Set bufsize=1 (line-buffered) for streaming.

Real-world recipes

Run a long command, stream to a log, kill on Ctrl-C

The canonical pattern for wrapping a long-running build/encode/test command — capture output to a log file, mirror to stdout, and shut down cleanly when interrupted.

python
import signal
import subprocess
import sys
from pathlib import Path

def run_logged(args: list[str], log_path: Path) -> int:
    log_path.parent.mkdir(parents=True, exist_ok=True)
    with subprocess.Popen(
        args,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
        bufsize=1,
    ) as proc, log_path.open("w", encoding="utf-8") as log:
        try:
            for line in proc.stdout:
                sys.stdout.write(line)
                log.write(line)
            return proc.wait()
        except KeyboardInterrupt:
            proc.send_signal(signal.SIGINT)
            try:
                return proc.wait(timeout=5)
            except subprocess.TimeoutExpired:
                proc.kill()
                return proc.wait()

if __name__ == "__main__":
    rc = run_logged(
        ["ffmpeg", "-i", "/home/alice/in.mp4", "/home/alice/out.mkv"],
        Path("/home/alice/logs/ffmpeg.log"),
    )
    sys.exit(rc)

Output:

text
ffmpeg version 6.1 Copyright (c) 2000-2026 the FFmpeg developers
...
frame=  240 fps=120 q=27.0 size=    1024kB time=00:00:08.00 bitrate=1048.5kbits/s
^C

Get the current git commit SHA

A common boilerplate for stamping build artifacts with the source revision. Fall back to unknown if the command isn't available (running in a release tarball without .git).

python
import subprocess

def git_sha() -> str:
    try:
        return subprocess.run(
            ["git", "rev-parse", "--short", "HEAD"],
            capture_output=True,
            text=True,
            check=True,
            cwd="/home/alice/projects/demo",
        ).stdout.strip()
    except (subprocess.CalledProcessError, FileNotFoundError):
        return "unknown"

print("build:", git_sha())

Output:

text
build: 7c4f3a2

Run a command on a remote host via SSH

Combine subprocess.run with ssh and shlex.join to invoke arbitrary commands on another machine. Pass -o BatchMode=yes to fail fast if interactive auth would be needed.

python
import shlex
import subprocess

def remote(host: str, *args: str) -> str:
    cmd = ["ssh", "-o", "BatchMode=yes", host, shlex.join(args)]
    return subprocess.run(
        cmd, capture_output=True, text=True, check=True, timeout=30
    ).stdout

print(remote("alicedev@myhost.local", "df", "-h", "/"))

Output:

text
Filesystem      Size  Used Avail Use% Mounted on
/dev/sda1       100G   45G   55G  45% /

Tail a file with tail -f and react per line

tail -f keeps printing as new lines arrive. Spawn it with Popen, iterate over proc.stdout, and process each line — a one-page implementation of a structured log monitor.

python
import re
import subprocess

ERROR_RE = re.compile(r"\bERROR\b")

with subprocess.Popen(
    ["tail", "-F", "/home/alice/logs/app.log"],
    stdout=subprocess.PIPE,
    text=True,
    bufsize=1,
) as proc:
    for line in proc.stdout:
        if ERROR_RE.search(line):
            print("ALERT:", line.rstrip())

Output:

text
ALERT: 2026-05-25 14:55:11 ERROR connection refused to db.internal
ALERT: 2026-05-25 14:55:14 ERROR timeout after 30s

Cap output and never block the event loop (asyncio)

In an async server, calling subprocess.run blocks the thread. Use asyncio.create_subprocess_exec plus asyncio.wait_for to enforce a timeout without stalling other requests.

python
import asyncio
from asyncio.subprocess import PIPE

async def run_bounded(args: list[str], timeout: float = 10.0) -> str:
    proc = await asyncio.create_subprocess_exec(*args, stdout=PIPE, stderr=PIPE)
    try:
        out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
    except asyncio.TimeoutError:
        proc.kill()
        await proc.wait()
        raise
    if proc.returncode:
        raise RuntimeError(f"rc={proc.returncode}: {err.decode()}")
    return out.decode()

async def main():
    print(await run_bounded(["uname", "-r"]))

asyncio.run(main())

Output:

text
6.5.0-15-generic