My Bash Script Is Getting Out of Hand
Part of Day One
This is part of Day One: Python for Platform Engineers.
You've got a deploy script. It started as 15 lines. Now it's 80 lines, it has nested if statements, error handling is a mess of || exit 1 chained everywhere, and your teammate asked what it does and you spent 10 minutes explaining it.
The commands themselves are fine. It's the logic around them that's the problem.
Python lets you keep running the exact same shell commands while giving you real control flow, proper error handling, and code that reads like what it's doing.
The Concept: The run() Wrapper
The run() wrapper handles the repetitive boilerplate of execution, logging, and error checking so your deployment logic stays clean.
flowchart TD
A([Start Command]) --> B[Log: → command string]
B --> C[subprocess.run]
C --> D{Return Code?}
D -->|0: Success| E[Log: Output]
D -->|Non-Zero| F{check=True?}
F -->|Yes| G[Log: ✗ Failed]
G --> H([sys.exit])
F -->|No| I[Return result to caller]
E --> I
style A fill:#1a202c,stroke:#cbd5e0,stroke-width:2px,color:#fff
style B fill:#2d3748,stroke:#cbd5e0,stroke-width:2px,color:#fff
style C fill:#2d3748,stroke:#cbd5e0,stroke-width:2px,color:#fff
style D fill:#4a5568,stroke:#cbd5e0,stroke-width:2px,color:#fff
style E fill:#2f855a,stroke:#cbd5e0,stroke-width:2px,color:#fff
style F fill:#4a5568,stroke:#cbd5e0,stroke-width:2px,color:#fff
style G fill:#c53030,stroke:#cbd5e0,stroke-width:2px,color:#fff
style H fill:#c53030,stroke:#cbd5e0,stroke-width:2px,color:#fff
style I fill:#2d3748,stroke:#cbd5e0,stroke-width:2px,color:#fff
The Pattern: A run() Wrapper
The core of wrapping shell commands in Python is a single reusable function:
- Exiting with
result.returncoderather than alwayssys.exit(1)preserves the original exit code, which matters if something is calling your script and checking why it failed.
Gotcha: The Shell Environment
When you run a command via subprocess.run(), Python starts a new process. It inherits your environment variables (PATH, USER, etc.), but it does not load your .bashrc, .zshrc, or shell aliases.
- Aliases won't work: If you have
alias k=kubectl,run(["k", "get", "pods"])will fail. Always use the full command name. - Shell functions won't work: If you've defined a bash function in your profile, Python won't see it.
- PATH matters: If
kubectlis in a non-standard location not in yourPATH, you'll need to use the absolute path or updateos.environ["PATH"]in your script.
With this function, your deploy script becomes readable:
- The image tag lives in the manifests directory — update the manifest, commit it, then deploy.
kubectl apply -fdeploys whatever is inmanifests/staging/. Never usekubectl set imageto mutate a running deployment directly.
Every command is logged before it runs. Every failure stops the script and shows you what failed. The logic reads like a deployment runbook.
Capturing Output When You Need It
Sometimes you need to use the output of a command in your script:
| Capturing command output | |
|---|---|
In bash this is $(kubectl get ...) inside an if. Fine for one level. Python scales better when you need to parse, compare, or do arithmetic with the result.
Handling Failures Selectively
Not every failed command should stop the script. Sometimes you want to try something, check if it worked, and handle each case:
In bash this is if kubectl get namespace ... 2>/dev/null; then. Python is cleaner, and you don't have to remember to redirect stderr.
Avoiding shell=True
You may have seen subprocess.run("kubectl apply -f manifests/", shell=True). Avoid it.
shell=True passes the command string to /bin/sh, which means shell metacharacters ($, ;, &&, etc.) are interpreted. If any part of that string contains a variable you didn't control, you have a code injection risk.
The list form is always safe. The only time you genuinely need shell=True is when you're using shell built-ins or piping (|) that can't be restructured. In those cases, use it explicitly and comment why.
A Complete Deployment Script
| Using the deploy script | |
|---|---|
This is the scaffold for every deploy script you'll write. Start here and add what your deploy actually needs.
Make It Actionable
Don't let these scripts sit in your Downloads folder.
1. Create a scripts/ directory in your project or a central ~/bin/ folder.
2. Add these to your team's internal tooling repo.
3. Use the --dry-run flag as your default way to test changes before they touch production.
Practice Exercises
Exercise 1: Add a rollback command
Extend the deploy script with a --rollback flag. When --rollback is passed, run kubectl rollout undo deployment/myapp instead of the normal deploy steps.
Answer
Exercise 2: Log every command to a file
Modify the run() function so every command and its output is written to a timestamped log file in addition to printing to the terminal.
Answer
Quick Recap
| Concept | What It Does |
|---|---|
subprocess.run([...]) |
Run a command; returns a CompletedProcess |
text=True |
Decode stdout/stderr as strings (not bytes) |
capture_output=True |
Capture stdout/stderr instead of printing |
result.returncode |
0 = success, non-zero = failure |
check=False |
Don't exit on failure — let caller decide |
shell=True |
Avoid unless you need shell features; injection risk |
What's Next
- The "Don't Do This" Guide — Security and safety rules before you run any of this in production
Further Reading
Official Documentation
subprocessmodule — Complete reference forsubprocess.run,Popen, and relatedclick— Building proper CLI interfaces (covered in depth in the Efficiency section)
Deep Dives
- subprocess security considerations — The official docs on
shell=Truerisks
Exploring Linux
- Pipes and Redirection — The bash patterns this article replaces:
|,>,2>&1, and why Python handles them more cleanly - Command Line Fundamentals — The foundation this article assumes you already have