Use subprocess for robust automation
Use subprocess for robust automation in Python for DevOps — learn how to run shell commands safely, capture output, handle errors, and apply best practices in hands-on exercises.
Focus: use subprocess for robust automation
Ever found yourself typing the same shell commands over and over, piping outputs to files, and praying nothing breaks? Or worse — writing a script that calls os.system() and then realizing you can't capture the output, check the exit code, or handle a hung process? That fragile approach is a ticket to silent failures in your automation. The subprocess module is Python's answer: a robust, battle-tested way to spawn, control, and communicate with external processes. In this lesson, you'll learn to use subprocess for robust automation — capturing output, handling errors, and even timing out unruly commands — so your DevOps scripts run predictably in any environment.
The problem this lesson solves
As a DevOps engineer, you live on the command line. You're often lacing together multiple tools: kubectl get pods, aws s3 sync, docker ps, or systemctl status. Manually running these is fine for a one-off, but when you're automating a deployment or a health check, you need more:
- Capture output for logging, parsing, or alerting.
- Check exit codes to know if a command actually succeeded.
- Pass input to commands that expect it via stdin.
- Prevent hangs when a command never returns.
- Avoid shell injection when you're dealing with user-provided input.
The old os.system() and os.popen() just don't cut it. They're simple but have real limitations: no direct output capture by default, no easy error handling, and they always invoke a shell, which invites security risks and quoting nightmares. subprocess was introduced back in Python 2.4 and was heavily redesigned in Python 3.5 with the run() function — the modern, recommended way to do everything. Let's dive in and see why subprocess is the go-to for robust automation.
Core concept / mental model
Think of subprocess as a remote control for your shell. Instead of typing a command and waiting, you're programmatically sending it, reading its response, and checking if it returned success or failure. The key abstraction is the process — a separate running program with its own stdin, stdout, and stderr. When you call a subprocess, your Python script blocks until the process finishes (unless you use Popen for async, but run() is simpler for most cases).
Here's the mental model in a nutshell:
subprocess.run()is your all-in-one sync helper. It runs the command, waits for it to finish, and returns aCompletedProcessobject containing the exit code and captured output.subprocess.Popen()is the low-level building block for async or interactive processes. You get a handle to the process and can read/write to it while it's still running.check=Truemakesrun()raise an exception if the command exits with a non-zero code — perfect for failing fast.capture_output=Truecaptures stdout and stderr into the result object, so you can inspect them without writing to files.
Think of run() as a smart wrapper that handles the common 95% of use cases. Once you master it, you'll never go back to os.system().
How it works step by step
Let's walk through the lifecycle of a subprocess call:
- Import the module:
import subprocess. - Build your command: Use a list of arguments, e.g.
["ls", "-l", "/tmp"], rather than a single string. This avoids shell interpretation and injection. - Call
run()with arguments: pass the command list, setcapture_output=Trueif you want stdout/stderr,check=Trueif you want errors to be raised, andtimeout=10if you want to kill long-running processes. - Inspect the result: The returned object has
.returncode,.stdout,.stderr(as bytes, decode them to strings), and you can also checkresult.returncode == 0for success. - Handle errors: Either check the return code manually, or let
CalledProcessErrorpropagate when usingcheck=True. Decode output to strings using.decode()or by passingtext=Truetorun().
Pro tip: Always pass
text=True(oruniversal_newlines=Truein older versions) sostdoutandstderrare strings instead of bytes. This saves you from decoding every time.
The sequence is straightforward: create the process, wait for it, get the output and exit status. Now let's put it into practice.
Hands-on walkthrough
1. Run a simple command and capture output
Here's the basic pattern. We'll run ls and print the output:
import subprocess
result = subprocess.run(["ls", "-l"], capture_output=True, text=True)
print("Return code:", result.returncode)
print("Output:\n", result.stdout)
Expected output (simplified):
Return code: 0
Output:
total 8
drwxr-xr-x 2 user user 4096 Jan 1 12:00 my_project
...
The capture_output=True captures stdout and stderr, and text=True returns strings. If the command fails, result.returncode will be non-zero, and result.stderr will contain the error.
2. Fail fast with check=True
If you want the script to raise an exception on failure (which is ideal for pipelines), use check=True:
import subprocess
try:
subprocess.run(
["git", "status"],
capture_output=True,
text=True,
check=True,
)
print("Git status command succeeded.")
except subprocess.CalledProcessError as e:
print(f"Command failed with exit code {e.returncode}")
print(f"Error output: {e.stderr}")
# Raise again to halt the script, or handle gracefully
raise
If the command fails, you get a CalledProcessError with the return code and error output. Note that output is not printed automatically — you have to print it if you want it visible.
3. Pass input via stdin
Some commands expect input from stdin. For example, bc (calculator) reads expressions from stdin. Here's how to pipe input:
import subprocess
result = subprocess.run(
["bc"],
input="2 + 2\n",
capture_output=True,
text=True,
)
print(result.stdout) # Output: 4
The input argument passes a string to the process's stdin. Combine with capture_output=True to capture the result.
4. Timeout to prevent hangs
When a command might hang (e.g., a network call), set a timeout. This is crucial for robust automation:
import subprocess
try:
subprocess.run(
["sleep", "60"],
timeout=5,
)
print("Completed without timeout.")
except subprocess.TimeoutExpired:
print("The process took too long and was killed.")
The timeout parameter kills the process after the specified seconds. Without it, your automation could hang forever.
5. Real-world DevOps example: check service status
Let's combine everything to check if a service is running and restart it if not (like a mini self-healing script):
import subprocess
import sys
def service_status(service_name):
result = subprocess.run(
["systemctl", "is-active", service_name],
capture_output=True,
text=True,
)
return result.returncode == 0, result.stdout.strip()
def restart_service(service_name):
print(f"Restarting {service_name}...")
result = subprocess.run(
["sudo", "systemctl", "restart", service_name],
capture_output=True,
text=True,
)
if result.returncode != 0:
print(f"Failed to restart: {result.stderr}")
sys.exit(1)
print("Service restarted.")
service = "nginx"
is_active, status = service_status(service)
if not is_active:
print(f"{service} is {status}. Restarting...")
restart_service(service)
else:
print(f"{service} is {status}.")
This shows the full pattern: capture output, check return code, and take corrective action. The script fails fast when restarting fails.
Compare options / when to choose what
There are several ways to run external commands in Python. Here's a comparison to help you choose:
| Method | Use case | Pros | Cons |
|---|---|---|---|
os.system() |
Simple fire-and-forget | Simple | No output capture, uses shell, no error handling |
os.popen() |
Read/write output with a file-like object | Slightly better than system |
Still limited, not recommended |
subprocess.run() |
Most everyday automation | Complete control, captures output, check errors, timeout | Blocking, one process at a time |
subprocess.Popen() |
Advanced: non-blocking, streaming, interactive | Async, fine-grained control | More verbose, manual management |
plumbum (third-party) |
Shell-like syntax in Python | Elegant, high-level | Not in standard library, extra dependency |
When to use what:
- For 95% of automation tasks,
subprocess.run()is your best friend. - If you need to run multiple processes concurrently or stream output, reach for
Popen. - Avoid
os.system()andos.popen()in modern code — they're legacy.
Pro tip: If you're tempted to use
os.system(), remember thatsubprocess.run()withshell=Truecan replicate it, but you lose the security benefits. For robust automation, always prefer the list form without a shell.
Troubleshooting & edge cases
Here are common pitfalls and how to fix them:
FileNotFoundErrorwhen running commands likegitorkubectl: The command might not be in the PATH. Use the full path, e.g.,/usr/bin/git.UnicodeDecodeErrorwhen capturing output: Addtext=Trueto get strings instead of bytes. If you still get bytes, decode explicitly:output.decode('utf-8', errors='ignore').- Command hangs indefinitely: Always set
timeoutto a reasonable value. This is critical for network commands. - Getting
CalledProcessErrorwhen you expected success: Double-check the command's exit code. Many tools return non-zero for non-critical errors (e.g.,grepreturns 1 when no match). Handle that specifically. - Scary shell injection vulnerabilities: Never pass user input as a single string with
shell=True. Use a list of arguments to avoid shell interpretation. - Output is empty but the command seemed to work: Some tools write to stderr instead of stdout. Capture both and merge if needed.
- Process not killed by timeout:
timeoutkills the process, but child processes may remain. Usestart_new_session=Trueand kill the process group for full cleanup.
What you learned & what's next
In this lesson, you've learned to use subprocess for robust automation. You now understand:
- The problem that
subprocesssolves: capturing output, checking exit codes, passing input, and preventing hangs. - The mental model: using
run()as a synchronous, high-level control;Popen()for advanced async. - Step-by-step: building commands as lists, capturing output, using
check=True, timeouts, and stdin. - How to choose the right approach: compare
subprocess.run(),Popen(), and legacy methods. - Troubleshooting: handling missing commands, decoding bytes, avoiding injection, and dealing with hangs.
You are now equipped to write deterministic, safe automation scripts. In the next lesson, we'll explore scheduling and parallel execution — how to run multiple subprocesses concurrently or in a cron-like fashion to build even more powerful DevOps tools.
Next step: Go ahead and replace an
os.system()call in one of your existing scripts withsubprocess.run()— see how it feels to capture output and handle errors properly. Practice makes perfect!
Now go forth and automate robustly!
Practice recap
To solidify what you've learned, take one of your existing automation scripts and refactor it to use subprocess.run() with capture_output=True and error handling. Then extend it by adding a timeout to prevent hangs. Finally, write a small function that runs a system command, checks the return code, and logs both stdout and stderr — you'll be building production-ready automation in no time.
Common mistakes
- Using
os.system()instead ofsubprocess.run()— you lose output capture and error handling. - Forgetting
text=Trueorcapture_output=True, leading to byte strings and no captured output. - Not setting a
timeouton commands that could hang, causing your automation to stall forever. - Using a single string with
shell=Truewhen handling user input — risks shell injection. - Ignoring the return code and assuming success — a non-zero return code often indicates failure even if stdout looks fine.
Variations
subprocess.Popen()for real-time streaming or non-blocking operations where you need to interact with the process while it runs.- The third-party
plumbumlibrary offers shell-like syntax and higher-level abstractions. shandsargeare other third-party alternatives with similar goals but different APIs.
Real-world use cases
- Wrapping
kubectl get podsin a Python script to check deployment health and automatically restart if not ready. - Using
subprocessto runaws s3 syncas part of a backup script, capturing output for logging and alerting. - Implementing a self-healing service monitor that uses
systemctlcommands to check status and restart failed services.
Key takeaways
subprocess.run()is the modern, recommended way to run external commands in Python.- Always capture output with
capture_output=Trueand decode bytes withtext=True. - Use
check=Trueto fail fast on non-zero exit codes and handleCalledProcessError. - Set a
timeoutto prevent automation from hanging on unruly commands. - Prefer command lists over strings with
shell=Trueto avoid shell injection. - Understand when to use
Popen()overrun()for advanced non-blocking scenarios.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.