Python Advance 📂 Advance topics · 3 of 3 44 min read

Python Subprocess — Running External Commands

Master Python's subprocess module — the standard way to run external commands, capture their output, chain them with pipes, and orchestrate CLI tools from Python. Covers subprocess.run for the 90% case, Popen for streaming and long-running processes, pipes for chaining commands like ps | grep | wc, timeouts, error handling, security around shell=True, and practical DevOps examples with runnable code.

Section 01

The Story That Explains Subprocess

Hiring a Specialist Instead of Doing It Yourself
You need a haircut. You could learn hairdressing, buy the scissors, and cut your own hair — or you could walk into a salon, ask the barber to do it, wait outside, and collect the result. The barber is a separate person with separate tools. You give them instructions, they work independently, and eventually hand back what you asked for.

That's exactly what Python's subprocess module does. Instead of reimplementing git, ffmpeg, ls, ping, or a compiled binary in pure Python, you spawn a new operating-system process that runs the real tool, capture whatever it prints, and get on with your day.

Every operating system runs work as processes. When your Python script needs to run ls -la, invoke ffmpeg to transcode a video, call git status, or execute any other executable on the system, it uses the subprocess module to fork a child process, hand it the command, and communicate with it through three streams: stdin, stdout, and stderr.

💡
The Core Insight

subprocess is Python's official replacement for older, unsafe functions like os.system(), os.popen(), and commands.getoutput(). It gives you full control over arguments, streams, timeouts, environment variables, working directory, and return codes — all in one consistent API.


Section 02

The Foundation — What Actually Happens

When you call subprocess.run(["ls", "-la"]), Python asks the operating system to:

FIGURE 1 — Parent & Child Process Anatomy
PARENT PROCESS python your_script.py subprocess.run([...]) waits for child… reads its output receives exit code CHILD PROCESS /bin/ls -la stdin (0) stdout (1) stderr (2) exits with code 0…255 input output errors OPERATING SYSTEM — creates the child, wires up pipes, delivers exit codes Two independent processes, connected by three streams

The child runs in its own memory space with its own PID. The only bridge back to Python is three file descriptors: stdin (0), stdout (1), stderr (2), plus the exit code.

🔑
Three Streams You'll See Everywhere

stdin is what the child reads. stdout is normal output. stderr is error / diagnostic output. On Unix these are file descriptors 0, 1, 2. Every subprocess API in Python maps to these three.


Section 03

subprocess.run — The 90% Case

subprocess.run() is your default. It runs a command, waits for it to finish, and returns a CompletedProcess object with the exit code and (optionally) captured output. This is what you should reach for first.

import subprocess

# Simplest possible call — just run it, wait, done
result = subprocess.run(["echo", "Hello, Mohit!"])
print(f"return code: {result.returncode}")
OUTPUT
Hello, Mohit! return code: 0

Capturing Output — capture_output + text

import subprocess

result = subprocess.run(
    ["ls", "-la", "/tmp"],
    capture_output=True,        # capture stdout + stderr
    text=True                    # decode bytes to str
)

print("--- STDOUT ---")
print(result.stdout[:200])
print("--- STDERR ---")
print(result.stderr or "(none)")
print(f"exit code: {result.returncode}")
OUTPUT
--- STDOUT --- total 48 drwxrwxrwt 12 root root 4096 Jul 16 11:03 . drwxr-xr-x 20 root root 4096 Jul 1 09:22 .. drwx------ 3 root root 4096 Jul 16 08:11 systemd-private-... --- STDERR --- (none) exit code: 0
⚠️
Always Pass a List, Not a String

Prefer ["ls", "-la", "/tmp"] over "ls -la /tmp". A list bypasses the shell entirely — no space-splitting bugs, no injection risk, and filenames with spaces work naturally. Only pass a string if you also set shell=True (which you should avoid — see Section 10).


Section 04

Handling Errors — check=True and returncode

Every process ends with an exit code: 0 for success, any non-zero value for failure. By default subprocess.run does not raise on non-zero exits — you must check explicitly, or pass check=True.

❌ Without check=True
Behaviour
Command fails silently
Script continues as if OK
You must manually check .returncode
Easy to miss failures
✅ With check=True
Behaviour
Raises CalledProcessError on non-zero
Fail-fast style — safe by default
Exception carries stdout, stderr, returncode
Recommended for automation scripts
import subprocess

# Command that will fail — no such file
try:
    result = subprocess.run(
        ["cat", "/does/not/exist"],
        capture_output=True,
        text=True,
        check=True                # raise on non-zero exit
    )
except subprocess.CalledProcessError as e:
    print(f"Command failed with code {e.returncode}")
    print(f"stderr: {e.stderr.strip()}")
    print(f"cmd:    {e.cmd}")
OUTPUT
Command failed with code 1 stderr: cat: /does/not/exist: No such file or directory cmd: ['cat', '/does/not/exist']
🏆
The Safe Default Pattern

For any automation script, always use check=True, capture_output=True, text=True. This gives you fail-fast behaviour, readable strings instead of bytes, and full access to stderr when things go wrong.


Section 05

Timeouts — Never Wait Forever

A hung process will freeze your script indefinitely. Always set a timeout when you're not sure how long a command will take.

import subprocess

try:
    result = subprocess.run(
        ["ping", "-c", "100", "google.com"],
        capture_output=True,
        text=True,
        timeout=3                # seconds — kill after this
    )
    print(result.stdout)
except subprocess.TimeoutExpired as e:
    print(f"Timed out after {e.timeout}s")
    print(f"Partial output: {e.stdout[:100] if e.stdout else 'none'}")
OUTPUT
Timed out after 3s Partial output: PING google.com (142.250.183.14) 56(84) bytes of data. 64 bytes from ... time=8.42 ms
⚠️
TimeoutExpired Kills the Child — But Cleans Up

When the timeout fires, Python sends SIGKILL to the child and waits for cleanup. The partial output collected before the kill is available on e.stdout and e.stderr. Never rely on the child gracefully finishing after a timeout.


Section 06

subprocess.run — Key Arguments Reference

ArgumentDefaultPurposeRecommendation
argsCommand as list of stringsAlways a list, never a string
capture_outputFalseCapture stdout & stderrTrue when you need the output
textFalseDecode bytes to str using default encodingAlmost always True
checkFalseRaise on non-zero exit codeTrue for automation, False for probing
timeoutNoneKill child after N secondsSet on any command that could hang
inputNoneSend string/bytes to child's stdinUse with text=True for string input
cwdNoneWorking directory to run inPrefer over os.chdir
envNoneEnvironment variables dictCopy os.environ first, then modify
shellFalseRun through /bin/shKeep False (see Section 10)
encodingNoneOverride text decoding'utf-8' if defaults misbehave

Passing stdin — sending data to the child

import subprocess

# Sort a list of names by piping stdin
names = "charlie\nalice\nbob\ndave"

result = subprocess.run(
    ["sort"],
    input=names,                # fed to child's stdin
    capture_output=True,
    text=True,
    check=True
)
print(result.stdout)
OUTPUT
alice bob charlie dave

Custom env & cwd

import subprocess, os

# Run a script in a specific directory with a modified env
custom_env = os.environ.copy()
custom_env["MY_API_KEY"] = "secret-token-123"
custom_env["PYTHONUNBUFFERED"] = "1"

result = subprocess.run(
    ["bash", "-c", "echo $MY_API_KEY; pwd"],
    cwd="/tmp",
    env=custom_env,
    capture_output=True,
    text=True
)
print(result.stdout)
OUTPUT
secret-token-123 /tmp

Section 07

Popen — Low-Level Control for Streaming & Long-Running Jobs

subprocess.run is built on top of subprocess.Popen. Use Popen directly when you need to:

🔊
Stream Output Live
line by line
Read stdout as the child produces it — for progress bars, log tailing, or long builds. run() waits until the child finishes; Popen lets you read while it runs.
🔄
Interact With It
read + write
Send input, read output, send more input based on what you saw. run() sends stdin once and closes it; Popen keeps the pipes open for back-and-forth.
📱
Non-Blocking Waits
poll(), terminate()
Fire off a background process, do other work, occasionally check if it's done. Use poll() for status, terminate() / kill() to stop it.
FIGURE 2 — Popen Lifecycle
1. SPAWN Popen([...]) returns instantly 2. RUNNING p.stdout.readline() p.stdin.write(...) 3. CHECK p.poll() → None or int still running / done? 4. FINISH p.wait() / p.kill() returncode is set loop until finished Popen returns immediately — you drive the lifecycle Unlike run(), the parent process keeps working while the child runs.

The key difference from run(): Popen doesn't block. You spawn, then poll, read, write, and finally wait or kill. This is what enables live progress bars and interactive tools.

Streaming stdout line by line

import subprocess

# Simulate a long-running task that emits output over time
p = subprocess.Popen(
    ["bash", "-c",
     "for i in 1 2 3 4 5; do echo 'line '$i; sleep 1; done"],
    stdout=subprocess.PIPE,
    stderr=subprocess.STDOUT,       # merge stderr into stdout
    text=True,
    bufsize=1                        # line-buffered
)

# Read one line at a time as it's produced
for line in p.stdout:
    print(f"[live] {line.rstrip()}")

exit_code = p.wait()
print(f"\ndone, exit={exit_code}")
OUTPUT
[live] line 1 [live] line 2 [live] line 3 [live] line 4 [live] line 5 done, exit=0
Two Buffering Gotchas

(1) Set bufsize=1 for line-buffered reads. (2) The child may buffer its own output. To force it to flush, run it with python -u, set PYTHONUNBUFFERED=1 in the env, or use stdbuf -oL. Otherwise you'll see all output appear at once when the child exits.


Section 08

Pipes — Chaining Commands Like the Shell

The shell command ps aux | grep python | wc -l chains three programs: ps's output feeds grep's input; grep's output feeds wc's input. In Python you build the same chain by connecting one process's stdout to another's stdin.

FIGURE 3 — Piping Three Commands Together
ps aux lists all processes produces ~200 lines grep python keeps only matches produces 5–10 lines wc -l counts lines produces 1 number | stdout→stdin | stdout→stdin shell: ps aux | grep python | wc -l Final result: 7

Each pipe (|) connects one process's stdout directly to the next process's stdin. All three run concurrently — no intermediate file needed.

Building the pipeline in Python

import subprocess

# Equivalent of:  ps aux | grep python | wc -l
p1 = subprocess.Popen(
    ["ps", "aux"],
    stdout=subprocess.PIPE
)
p2 = subprocess.Popen(
    ["grep", "python"],
    stdin=p1.stdout,                # feed p1's output
    stdout=subprocess.PIPE
)
p3 = subprocess.Popen(
    ["wc", "-l"],
    stdin=p2.stdout,                # feed p2's output
    stdout=subprocess.PIPE,
    text=True
)

# CRITICAL: close upstream stdouts in the parent so SIGPIPE works
p1.stdout.close()
p2.stdout.close()

count = p3.communicate()[0].strip()
print(f"python processes: {count}")
OUTPUT
python processes: 7
⚠️
Close Upstream stdouts — Or Deadlock

After chaining, close the parent's copies of intermediate pipes (p1.stdout.close()). Otherwise if p2 or p3 dies early, p1 won't get SIGPIPE and will block forever waiting for someone to read its output.

The shortcut — do it all in one line

import subprocess

# Get the disk usage of /var by grepping df -h output
result = subprocess.run(
    ["grep", "/var"],
    input=subprocess.run(["df", "-h"], capture_output=True, text=True).stdout,
    capture_output=True,
    text=True
)
print(result.stdout)
OUTPUT
/dev/sda2 50G 12G 36G 25% /var

Section 09

communicate() — Send and Receive in One Shot

When you spawn with Popen and need to send input and then read all output, use communicate(). It writes the input, closes stdin, reads all of stdout and stderr, and waits for the child to exit — all safely, without deadlocking.

import subprocess

p = subprocess.Popen(
    ["python3"],                # interactive Python
    stdin=subprocess.PIPE,
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE,
    text=True
)

script = """
import math
print('sqrt(2) =', math.sqrt(2))
print('pi     =', math.pi)
"""

stdout, stderr = p.communicate(input=script, timeout=5)
print("--- output ---")
print(stdout)
print(f"exit: {p.returncode}")
OUTPUT
--- output --- sqrt(2) = 1.4142135623730951 pi = 3.141592653589793 exit: 0
📈
Why Not Just Write & Read Directly?

Manually calling p.stdin.write() then p.stdout.read() can deadlock: if the child fills its stdout pipe (~64 KB on Linux) before you read from it, it blocks writing, and you block waiting for it to finish. communicate() uses threads/select to read and write concurrently, avoiding this.


Section 10

The shell=True Trap — Security & Injection

shell=True runs your command through /bin/sh -c "...". Convenient, but if any part of the command string comes from user input, you have a shell injection vulnerability.

FIGURE 4 — Why shell=True Is Dangerous
✓ SAFE — shell=False (default) user_input = "; rm -rf /" subprocess.run( ["cat", user_input] ) Result: cat: '; rm -rf /' No such file — harmless error ✗ DANGEROUS — shell=True user_input = "; rm -rf /" subprocess.run( f"cat {user_input}", shell=True) Result: cat filename; rm -rf / Shell executes BOTH commands The semicolon becomes a command separator. Attacker owns your machine.

List form treats input as a filename argument. String form with shell=True treats it as shell syntax. This is why shell=True is Python's biggest CVE source in production automation scripts.

❌ NEVER do this with user input
subprocess.run(f"cat {user_file}", shell=True)
subprocess.run("grep " + query + " log.txt", shell=True)
os.system(f"rm {filename}")
✅ Do this instead
subprocess.run(["cat", user_file])
subprocess.run(["grep", query, "log.txt"])
os.remove(filename) (or subprocess with list)
🔒
When Is shell=True Acceptable?

Only when the command is a hard-coded string with no interpolated user input, AND you genuinely need shell features (pipes, globs, redirects, env expansion). Even then, prefer chaining Popen or using shlex.split() and list form wherever possible.


Section 11

Real-World Example — Git Status Dashboard

Let's build a small utility that scans multiple git repositories and reports which have uncommitted changes. Uses subprocess.run, check=False, timeouts, cwd, and error handling — all the pieces you need in production.

import subprocess
from pathlib import Path

def git_status(repo_path: Path) -> dict:
    """Return a summary of the git status of one repo."""
    try:
        # --porcelain gives machine-readable output
        result = subprocess.run(
            ["git", "status", "--porcelain", "-b"],
            cwd=repo_path,
            capture_output=True,
            text=True,
            timeout=10,
            check=True
        )
    except subprocess.CalledProcessError as e:
        return {"name": repo_path.name, "error": e.stderr.strip()}
    except subprocess.TimeoutExpired:
        return {"name": repo_path.name, "error": "timed out"}
    except FileNotFoundError:
        return {"name": repo_path.name, "error": "git not installed"}

    lines = result.stdout.splitlines()
    branch = lines[0].replace("## ", "") if lines else "?"
    changes = [l for l in lines[1:] if l.strip()]

    return {
        "name":    repo_path.name,
        "branch":  branch,
        "dirty":   len(changes) > 0,
        "changes": len(changes)
    }

# Scan multiple repos
repos = [Path("~/work/api").expanduser(),
         Path("~/work/web").expanduser(),
         Path("~/work/docs").expanduser()]

for repo in repos:
    info = git_status(repo)
    if "error" in info:
        print(f"{info['name']:12s}  ✗ {info['error']}")
    else:
        status = "DIRTY" if info["dirty"] else "clean"
        print(f"{info['name']:12s}  {status:6s}  ({info['changes']} changes)  [{info['branch']}]")
OUTPUT
api DIRTY (3 changes) [main] web clean (0 changes) [feature/auth...origin/feature/auth] docs ✗ not a git repository

Section 12

Real-World Example — Live Log Tailing with Popen

Here's a small tool that streams the last 100 lines of a log file and follows new entries live — like tail -f — while adding colour-tagged severity levels.

import subprocess

def colorize(line: str) -> str:
    if "ERROR" in line:
        return f"\033[31m{line}\033[0m"       # red
    if "WARN" in line:
        return f"\033[33m{line}\033[0m"       # yellow
    if "INFO" in line:
        return f"\033[36m{line}\033[0m"       # cyan
    return line

def tail_log(path: str, lines: int = 100):
    p = subprocess.Popen(
        ["tail", "-n", str(lines), "-f", path],
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        bufsize=1
    )
    try:
        for line in p.stdout:
            print(colorize(line.rstrip()))
    except KeyboardInterrupt:
        print("\nStopping tail…")
        p.terminate()                        # polite SIGTERM first
        try:
            p.wait(timeout=2)
        except subprocess.TimeoutExpired:
            p.kill()                        # SIGKILL if it ignores us
            p.wait()

tail_log("/var/log/app.log")
OUTPUT
2026-07-16 14:22:03 INFO server started on :8080 2026-07-16 14:22:04 INFO connected to database 2026-07-16 14:22:15 WARN slow query (1.4s): SELECT * FROM orders 2026-07-16 14:22:17 ERROR failed to reach payment gateway: timeout 2026-07-16 14:22:18 INFO retrying payment gateway (attempt 2/5) ^C Stopping tail…
🔑
Graceful Shutdown Pattern

Always terminate() (SIGTERM) first, wait a couple of seconds, then kill() (SIGKILL) if the child ignored the polite signal. Never kill() straight away — it doesn't let the child flush buffers, release locks, or clean up temp files.


Section 13

run vs Popen — Which to Use When

Propertysubprocess.runsubprocess.Popen
Blocks until child finishes?YesNo — returns immediately
Ease of useVery simple — one callMore setup, more control
Stream output line-by-lineNo — output only after doneYes — read as it arrives
Send/receive interactivelyOnce, via input=Full duplex via communicate()
Chain into pipesAwkwardNatural — stdin=prev.stdout
Kill from parentOnly via timeoutterminate() / kill() / send_signal()
Best fit90% of everyday scriptsLong-running, streaming, interactive
📈
The Practitioner's Rule

Start with subprocess.run(...). If you find yourself needing streaming output, live progress, interactive I/O, background execution, or chained pipes, only then reach for Popen. Over 90% of production subprocess code should never touch Popen directly.


Section 14

Common Pitfalls

Using shell=True with user input
Textbook shell injection vulnerability. Attacker adds ; rm -rf / to your input. Never do this in production.
use list form, not strings
Reading stdout with read() then writing stdin
Deadlock waiting to happen. Child fills its output buffer, blocks writing; you're blocked reading a not-yet-flushed pipe. Use communicate().
always communicate()
No timeout on network commands
ping, curl, ssh, git clone, apt-get can all hang forever. Your script becomes unmonitorable. Always set a timeout.
timeout= is not optional
Use text=True (or encoding=)
Without it, stdout/stderr are bytes. You'll spend half your life calling .decode(). Just enable text mode.
text=True everywhere
Prefer check=True in scripts
Fail-fast is safer than silent-continue. Non-zero exits raise CalledProcessError with full context.
safe by default
Set PYTHONUNBUFFERED for child Python
When spawning python3 as a child, its stdout is block-buffered when not connected to a TTY. Set env{"PYTHONUNBUFFERED": "1"} or use python -u.
flush earlier

Section 15

Golden Rules

🌲 Subprocess — Non-Negotiable Rules
1
Always pass command args as a list of strings, not a single string. ["git", "commit", "-m", msg] — never f"git commit -m {msg}". List form skips the shell entirely.
2
Set text=True unless you're specifically handling binary data. Bytes-mode is a source of endless .decode('utf-8') boilerplate and encoding bugs.
3
For automation scripts, use check=True. Fail-fast on non-zero exits with a clear CalledProcessError is safer than silently ignoring failures.
4
Always set a timeout= on network calls, external services, and anything you don't fully control. A hung subprocess with no timeout is unrecoverable from within Python.
5
Never use shell=True with any input that could contain user data. Shell injection is Python's most common CVE class in DevOps scripts. List form + shell=False is completely safe.
6
Use subprocess.run() for the 90% case. Only reach for Popen when you need streaming output, interactive I/O, chained pipes, or background execution.
7
With Popen pipelines, close the upstream .stdout handles in the parent after wiring them into the next child. Otherwise SIGPIPE never fires and processes can hang.
8
Shut down long-running children gracefully: send terminate(), wait a few seconds, only then kill(). This lets the child flush buffers, release locks, and clean up temp files.
You have completed Advance topics. View all sections →