Learning Goals
3 minBy the end of this lesson you can:
- Run an external command with
subprocess.run. - Pass arguments as a list — and explain why that's safer than a string.
- Read the result:
returncode, and raise on failure withcheck=True. - Recognise the
shell=Truecommand-injection trap and avoid it.
Warm-Up · Python as Glue
5 minLots of powerful tools have no Python library — but they all have a command line:
ffmpeg -i video.mov out.mp4 convert video formats git commit -m "automated backup" version control pdftk in.pdf cat 1-5 output a.pdf manipulate PDFs ping -c 1 example.com test connectivity
Instead of reimplementing these tools, Python can run them and react to their results. subprocess.run launches a program, waits for it to finish, and hands you back its exit code (and, next lesson, its output). Your script becomes a conductor for the whole system.
New Concept · subprocess.run
14 minThe basic call
import subprocess result = subprocess.run(["python", "--version"]) print(result.returncode) # 0 means success
Pass the command as a list: the program name first, then each argument as its own string element. subprocess.run launches it, waits, and returns a CompletedProcess object.
Why a list, not a string?
This is the heart of the lesson. Compare:
subprocess.run(["ls", "-l", "my folder"]) # ✅ safe: 3 clear arguments subprocess.run("ls -l my folder") # ✗ wrong: looks for a program literally named "ls -l my folder"
The list form passes arguments directly to the program, with no shell in between to misinterpret spaces, quotes, or special characters. "my folder" stays one argument. This is both safer and more predictable.
Checking success
result = subprocess.run(["git", "status"]) if result.returncode != 0: print("git failed!") # or let it raise automatically: subprocess.run(["git", "status"], check=True) # raises CalledProcessError on non-zero
By convention, exit code 0 means success and anything else means failure (you saw this with sys.exit in Lesson 2). check=True turns a failure into a Python exception so you can't accidentally ignore it.
Handling "program not found"
try: subprocess.run(["ffmpeg", "-version"], check=True) except FileNotFoundError: print("ffmpeg isn't installed or isn't on PATH") except subprocess.CalledProcessError as e: print(f"ffmpeg ran but failed with code {e.returncode}")
Two different failures: FileNotFoundError (the program doesn't exist) vs. CalledProcessError (it ran but returned non-zero). Good automation distinguishes them.
The shell=True trap
shell=True with user inputYou'll see subprocess.run("rm " + filename, shell=True) online. If filename ever comes from a user and contains "; rm -rf ~", the shell runs both commands — a classic command injection attack. The list form (["rm", filename]) has no shell to exploit: the malicious text is treated as a literal (harmless) filename. Default to the list form, always.
A few handy options
subprocess.run(["pytest"], cwd="my_project") # run in a specific folder subprocess.run(["sleep", "10"], timeout=5) # raises TimeoutExpired after 5s
Worked Example · An Auto-Commit Script
12 minGoal: a script that stages all changes and commits them with a timestamped message — but only if there's actually something to commit. (We'll read git's output properly next lesson; for now we use exit codes.)
import subprocess from datetime import datetime def run(cmd: list[str]) -> int: """Run a command in the current repo, return its exit code.""" print(">", " ".join(cmd)) return subprocess.run(cmd).returncode def auto_commit() -> None: # 'git diff --quiet' exits 0 if NO changes, 1 if there ARE changes changes = subprocess.run(["git", "diff", "--quiet"]).returncode != 0 staged = subprocess.run(["git", "diff", "--cached", "--quiet"]).returncode != 0 if not (changes or staged): print("Nothing to commit — working tree clean.") return run(["git", "add", "-A"]) msg = "auto: " + datetime.now().strftime("%Y-%m-%d %H:%M:%S") try: subprocess.run(["git", "commit", "-m", msg], check=True) print("Committed ✅") except subprocess.CalledProcessError: print("Commit failed — check git output above.") auto_commit()
> git add -A [develop abc1234] auto: 2026-05-28 14:30:12 3 files changed, 21 insertions(+) Committed ✅
Read the code
Notice every command is a list — ["git", "commit", "-m", msg] — so the message stays one argument even with spaces, and no shell can mangle it. We use exit codes cleverly: git diff --quiet returns non-zero when there are changes, which we read to decide whether to commit at all. check=True on the commit turns a failure into a catchable exception. This is a real script people run on a schedule — and you'll wire it to a timer in Lesson 35.
Try It Yourself
13 minRun python --version and pip --version via subprocess and print whether each succeeded (returncode 0).
Write safe_run(cmd) that runs a command list, catches both FileNotFoundError and CalledProcessError, and returns True/False for success. Test it with a real command and a fake one.
Hint
import subprocess def safe_run(cmd): try: subprocess.run(cmd, check=True) return True except FileNotFoundError: print(f"Not found: {cmd[0]}") except subprocess.CalledProcessError as e: print(f"Failed ({e.returncode}): {' '.join(cmd)}") return False print(safe_run(["python", "--version"])) # True print(safe_run(["definitely_not_real"])) # False
Explain in a comment why subprocess.run(f"cat {name}", shell=True) is dangerous if name is user input, and rewrite it safely with a list. What input would have caused damage?
Hint
# DANGEROUS: name = "notes.txt; rm -rf ~" runs the rm too! # subprocess.run(f"cat {name}", shell=True) # SAFE: the whole string is one literal filename argument subprocess.run(["cat", name]) # "notes.txt; rm -rf ~" is just a (missing) filename
Mini-Challenge · The Command Sequencer
8 minWrite run_pipeline(steps) that takes a list of command-lists and runs them in order, stopping at the first failure and reporting which step broke. Each step that succeeds prints a ✅; the failing one prints ❌ and the rest are skipped. (This is the seed of a CI runner.)
Show a sample solution
import subprocess def run_pipeline(steps: list[list[str]]) -> bool: for i, cmd in enumerate(steps, start=1): label = " ".join(cmd) try: subprocess.run(cmd, check=True) print(f"✅ step {i}: {label}") except (FileNotFoundError, subprocess.CalledProcessError): print(f"❌ step {i} failed: {label} — stopping.") return False print("All steps passed 🎉") return True run_pipeline([ ["python", "--version"], ["pytest", "-q"], ["git", "status"], ])
Non-negotiables: list-form commands, check=True, stop-on-first-failure, clear per-step status.
Recap
3 minsubprocess.run(["program", "arg1", "arg2"]) launches any command-line program and waits for it. Always pass arguments as a list — it's safer (no shell to misread spaces or run injected commands) and clearer. Check result.returncode (0 = success) or use check=True to raise on failure; catch FileNotFoundError (missing program) and CalledProcessError (non-zero exit) separately. Avoid shell=True, especially with any user input — that's the command-injection trap. Next lesson: capturing and reading the output.
Vocabulary Card
- subprocess
- The standard module for launching and controlling other programs.
- returncode
- A command's exit code: 0 for success, non-zero for failure.
- check=True
- Makes
runraiseCalledProcessErrorif the command fails. - command injection
- An attack where user input is run as a shell command — avoided by the list form.
Homework
4 minBuild doctor.py: a "system check" that runs a list of (label, command) pairs — e.g. checking python, pip, git, and node are installed — and prints a tidy report of which tools are present and which are missing. Use shutil.which OR a subprocess.run(..., check=True) wrapped in try/except, and never use shell=True.
Sample · doctor.py
import subprocess CHECKS = [ ("Python", ["python", "--version"]), ("pip", ["pip", "--version"]), ("git", ["git", "--version"]), ("Node", ["node", "--version"]), ] def check(cmd): try: subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) return True except (FileNotFoundError, subprocess.CalledProcessError): return False print("System check") print("-" * 24) for label, cmd in CHECKS: mark = "✅" if check(cmd) else "❌" print(f"{mark} {label}")
Non-negotiables: list-form commands, try/except for both failure types, a clean report, no shell=True.