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.
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.
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.
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.
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.
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.