Run Shell Commands from Python

Learn how to run shell commands from Python in this DevOps-focused lesson. We cover subprocess basics, capturing output, handling errors, and choosing between shell and non-shell execution. Includes a hands-on exercise and troubleshooting tips.

Focus: run shell commands from python

Sponsored

You've just spent 20 minutes manually SSH-ing into a server, typing the same systemctl restart nginx and tail -f /var/log/nginx/error.log commands you've typed a thousand times before. It's tedious, error-prone, and — let's be honest — not a great use of a DevOps engineer's brain. The good news? Python can automate all of that. In this lesson, you'll learn how to run shell commands from Python using the subprocess module, capture their output, handle errors gracefully, and choose the right execution strategy for your automation scripts. By the end, you'll be writing Python scripts that do the heavy lifting for you.

The problem this lesson solves

DevOps and SRE work is full of repetitive shell tasks: checking service status, copying logs, restarting processes, parsing ps output, or polling kubectl get pods. Doing these by hand is slow and leaky — you forget a flag, mistype a path, or miss a critical error. But your automation scripts also need to talk to the operating system, and Python's built-in tools for that are often misunderstood.

A common pitfall is reaching for os.system() or os.popen() because they look simple. They are simple, but they give you almost no control: you can't capture output directly, you can't pass arguments safely without shell injection risks, and error handling is clunky at best. This lesson replaces those crutches with the standard-library workhorse: the subprocess module.

Why this matters right now: as you build bigger automation pipelines (whether it's a CI/CD job, a deployment script, or a server-cleanup routine), you will need to run shell commands from Python reliably, capture their output, check their exit codes, and feed that info into your logic. Doing that wrong leads to silent failures, partial deployments, and hard-to-debug logs.

Core concept / mental model

Think of Python as the conductor of an orchestra. Each shell command (ls, grep, systemctl) is a musician that plays a single note. The subprocess module is the sheet music — it tells the orchestra what to play, how loud, and when to stop. Instead of just sending a command and hoping for the best, subprocess gives you three things: the command to run, the environment it runs in, and the ability to read the result.

Key terms

  • Process: an instance of a running program (like grep or ping).
  • Exit code: a number returned by a process when it finishes. 0 usually means success, any non-zero means an error.
  • Standard output (stdout): the text a program writes to the screen.
  • Standard error (stderr): the text a program writes for error messages.
  • Shell: the command interpreter (like bash or sh) that turns a string into a process.

The subprocess module in one diagram

Python script ⟶ subprocess.run() ⟶ creates child process ⟶ runs command ⟶ captures stdout/stderr/exit_code ⟶ returns CompletedProcess

The key takeaway: subprocess.run() is your main function. It runs a command, waits for it to finish, and gives you a CompletedProcess object with all the info you need.

How it works step by step

Running a shell command from Python is surprisingly straightforward once you break it down. The core pattern with subprocess.run() looks like this:

  1. Pick your command representation — either a list of arguments (["ls", "-l", "/tmp"]) or a single string (only when shell=True).
  2. Choose whether to use the shellshell=False by default and safest.
  3. Decide what to capture — set capture_output=True to get stdout and stderr, or stdout=subprocess.PIPE for finer control.
  4. Call subprocess.run() with your arguments and options.
  5. Check the exit code (usually via returncode or by raising CalledProcessError with check=True).
  6. Process the captured output — decode bytes to a string and parse it as needed.

That's it. Six steps cover 90% of your needs. The next section puts those steps into practice with real, runnable code.

Hands-on walkthrough

Let's write a small Python script that runs ls -l on a directory and prints the results. Create a file called list_dir.py:

import subprocess

# Run 'ls -l' on the current directory
try:
    result = subprocess.run(["ls", "-l"], capture_output=True, text=True, check=True)
    # 'text=True' makes stdout/stderr strings instead of bytes
    print(result.stdout)
except subprocess.CalledProcessError as e:
    print("Command failed with exit code", e.returncode)
    print(e.stderr)

When you run this script (python list_dir.py), you'll see the same output as running ls -l in your terminal, but now it's a string variable you can manipulate.

Capturing output for conditional logic

Often you want to use the output rather than just print it. For example, check if a certain process is running:

import subprocess

ps = subprocess.run(["pgrep", "-f", "nginx"], capture_output=True, text=True)

if ps.returncode == 0:
    print("nginx is running with PIDs:")
    print(ps.stdout)
else:
    print("nginx is NOT running")

The exit code does the heavy lifting: pgrep returns 0 if it found a match, 1 if not.

Handling environment variables and working directory

Sometimes your command needs a different environment or a different working directory:

import subprocess, os

# Set extra environment variables
env = os.environ.copy()
env["MY_VAR"] = "hello"

# Run a command in a specific directory
result = subprocess.run(
    ["pwd"],
    cwd="/tmp",          # working directory
    env=env,             # environment variables
    capture_output=True,
    text=True
)

print("Exit code:", result.returncode)
print("Output:", result.stdout.strip())

Expected output:

Exit code: 0
Output: /tmp

Real-world example: system check script

Here's a mini DevOps script that checks disk space and service status:

import subprocess

def run_cmd(cmd):
    """Run a shell command and return (exit_code, stdout, stderr)."""
    result = subprocess.run(cmd, capture_output=True, text=True)
    return result.returncode, result.stdout, result.stderr

# Check disk usage
exit_code, out, err = run_cmd(["df", "-h", "/tmp"])
if exit_code == 0:
    print(out)
else:
    print("Disk check failed:", err)

# Check if nginx process exists
exit_code, out, err = run_cmd(["pgrep", "-x", "nginx"])
if exit_code == 0:
    print("Nginx is up")
else:
    print("Nginx is down")

Pro tip: Always use text=True when you want string output. Without it, you get bytes that often need .decode('utf-8').

Compare options / when to choose what

While subprocess.run() is the go-to, Python offers several other ways to run shell commands. Here's a comparison to help you choose:

Method Best for Pros Cons
os.system() Quick, one-off commands (rarely in production) Simple No output capture, no error control, shell injection risk
os.popen() Simplistic output capture Simple, output as a file object No stderr capture, deprecated in favor of subprocess
subprocess.run() Most everyday tasks Captures stdout/stderr, exit code, safe by default Blocking, not ideal for long-running processes
subprocess.Popen() Need to interact with process in real time Full control over stdin/stdout/stderr, non-blocking More verbose and error-prone

For 95% of DevOps automation, subprocess.run() is the right choice. Use Popen only when you need to stream output or feed input incrementally.

When to use shell=True (and when not to)

  • Avoid shell=True when the command is static or uses user input — it opens you to shell injection.
  • Use shell=True only when you need shell features like pipes, redirection, or environment variable expansion in a quick script, and you trust the input.
  • Prefer passing a list of arguments (no shell) — it's safer and faster.

Troubleshooting & edge cases

Command not found

If you try to run a command that doesn't exist, subprocess.run() raises FileNotFoundError. Use a try/except:

import subprocess

try:
    subprocess.run(["nonexistentcmd"], check=True)
except FileNotFoundError:
    print("Command not found")

Exit code is non-zero but no error raised

By default, subprocess.run() does not raise an error on a non-zero exit code. Always check result.returncode or use check=True to raise CalledProcessError.

Output is in bytes

If you forgot text=True, you'll get bytes. Convert with .decode() or better, just add text=True.

Long-running commands deadlock

If a command outputs more than a pipe buffer to stdout/stderr and you didn't capture them, your script might block. Using capture_output=True handles this, but for very long outputs, consider capturing to a file.

Shell injection with user input

Building commands as strings with shell=True and user input is a security hole. This is the classic import os; os.system("ls " + user_input) disaster. Always pass a list to avoid shell injection.

What you learned & what's next

You now know how to run shell commands from Python using subprocess.run(), capture stdout/stderr, check exit codes, and handle common pitfalls. You can apply this to automate repetitive shell tasks, check system health, and integrate shell commands into larger Python automation pipelines. This is a cornerstone skill for DevOps automation — you'll use it in almost every serious script.

Great! The next lesson in this track is Managing environment variables in Python (lesson 7), where you'll learn to move beyond hard-coded configs and make your scripts portable across environments. See you there!

Practice recap

Write a Python script using subprocess.run() that runs ping -c 4 google.com, captures the output, and reports the packet loss percentage by parsing the output string. Then, modify the script to use check=True and observe the CalledProcessError when the ping fails (e.g., use a nonexistent domain). This exercise cements the core subprocess workflow you'll rely on daily.

Common mistakes

  • Using os.system() or os.popen() instead of subprocess — you lose output capture and get messy error handling.
  • Forgetting text=True and getting bytes output that requires manual decoding.
  • Using shell=True with user input, opening the door to shell injection — always pass a list of arguments when possible.
  • Not checking returncode and assuming success — a non-zero exit code silently passes unless you set check=True.

Variations

  1. For simple shell pipelines, you can use subprocess.run('ls -l | grep py', shell=True), but prefer Python's own file and string operations for safety and readability.
  2. If you need to interact with a running process, switch to subprocess.Popen with communicate() for fine-grained control over stdin and stdout.
  3. For async workflows, try asyncio.create_subprocess_shell to run commands without blocking the event loop.

Real-world use cases

  • A deployment script that restarts a service and waits for its health check to pass, automating zero-downtime rollouts.
  • A log analysis cron job that runs grep or awk over rotated archives and sends alerts if error patterns appear.
  • A resource cleanup tool that checks disk usage with df and deletes old temp files via rm when thresholds are exceeded.

Key takeaways

  • Use subprocess.run() as your primary way to execute shell commands from Python — it captures stdout, stderr, and exit codes cleanly.
  • Always prefer a list of arguments over a shell string to avoid injection and quoting bugs.
  • Check returncode or pass check=True to fail loudly on errors, never assume success.
  • Set text=True to get string output instead of bytes and avoid decode hassle.
  • Reserve shell=True for quick scripts where you trust the input and need pipes or glob expansion.
  • Handle FileNotFoundError for missing commands and watch for deadlock when capturing large outputs.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.