The Quiet Terminal

← Practical Tips

Calling system commands from Python with subprocess

2026-07-10 · 3 min read

Half my Python is glue around programs that already work: git, ffmpeg, that one image optimizer with flags I can never remember. subprocess is the standard library's door from Python out to the command line, and after a few years of wrapping things I can report that two habits cover almost everything: pass a list, and set check=True.

run() and what it gives back

import subprocess

result = subprocess.run(["git", "status", "--porcelain"], capture_output=True, text=True)
print(result.stdout)
 M notes.txt
?? draft-post.md

capture_output=True fills result.stdout and result.stderr instead of letting them print straight to your terminal; text=True makes them strings rather than bytes, which is what you want 95% of the time. result.returncode holds the exit code.

check=True, and why it matters

result = subprocess.run(["git", "pull"], capture_output=True, text=True)
print("pull done")   # prints even when the pull failed

subprocess.run(["git", "pull"])              # fails quietly: returncode 1, script continues
subprocess.run(["git", "pull"], check=True)  # fails loudly: CalledProcessError, right here

Without check=True, a failed command is just a returncode you never look at. The pull fails, the script sails on, and the failure resurfaces twenty lines later as something mysterious — an empty file, a merge conflict, a KeyError. With check=True, a nonzero exit raises CalledProcessError on the spot, with the command and the exit code in the message. My rule: check=True on every call unless I have an actual plan for the failure.

Lists, not strings

subprocess.run(["ffmpeg", "-i", "summer vacation.mov", "out.mp4"], check=True)

The filename has a space in it, and in a list that's fine — it stays one argument. Build the same command as a string and the shell splits it on spaces, so ffmpeg goes looking for a file called summer. The list isn't a style preference; it is the quoting. Python handles the messy characters, which is the main reason to be here instead of a shell script.

One sentence on the alternative: shell=True hands the command string to a real shell to interpret — reasonable with strings you wrote yourself, an injection risk the moment any part of it comes from user input, so I simply leave it off.

A real example: batch-converting videos

import subprocess
from pathlib import Path

for mov in Path("camera").glob("*.mov"):
    mp4 = mov.with_suffix(".mp4")
    subprocess.run(
        ["ffmpeg", "-y", "-i", str(mov), "-c:v", "libx264", "-crf", "23", str(mp4)],
        check=True,
    )
    print("converted:", mov.name)

Twelve lines, and my camera's files stop choking the living-room player. pathlib finds the files (I went on about it in an earlier post), the list carries the arguments safely, check=True makes a failed conversion loud instead of silent, and -y answers ffmpeg's overwrite prompt so the script doesn't stall waiting for a human who isn't there. One footnote from experience: ffmpeg chats constantly on stderr, so if the noise bothers you, add capture_output=True — just remember to still check the exit code, which check=True does for you.

The honest opinion

A lot of "subprocess is confusing" takes come from meeting strings, pipes, and shell=True all on day one. run() plus a list plus check=True is most of the job, it reads like what it is — run this program with these arguments and fail loudly — and it composes with the file-handling side of the standard library without ceremony.