Any Bash Script Past 200 Lines Belongs in Python
Any bash script past two hundred lines with conditionals should be rewritten in Python. Shorter, and finally testable.
Bash is superb glue for a dozen lines: run a command, check the exit code, pipe to the next thing. But it was never designed to be a programming language, and past a couple hundred lines with real branching it stops being glue and starts being a liability that no one on the team can safely change.
What bash doesn't have
The problems aren't stylistic — they're structural gaps:
- No real data structures. Associative arrays exist but are clumsy; anything nested (a list of records, JSON) means shelling out to
jqand stringly-typed hacks. - No error handling. There's no
try/except.set -euo pipefailhelps but is full of sharp edges — it doesn't fire inside command substitutions the way you'd expect, and a single unquoted$varthat's empty can silently glob or word-split into a destructive command. - No testability. There's no natural unit-test seam. You test by running the whole thing against real side effects, which on a deploy or migration script means testing in production.
A large shell script is one off-by-one quote away from rm -rf on the wrong path, and because it can't be unit-tested, no one refactors it. It calcifies.
The rewrite pays for itself
The same logic in Python is typically shorter, not longer, because you stop fighting the language:
import subprocess, sys
def run(cmd: list[str]) -> str:
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"{cmd[0]} failed: {result.stderr.strip()}")
return result.stdout
def deploy(env: str) -> None:
if env not in ("staging", "production"):
raise ValueError(f"unknown env: {env}")
artifacts = run(["build", "--env", env]).splitlines()
...
A ~200-line bash script often becomes ~150 lines of Python with real exceptions, typed data, and functions you can import and test. And subprocess arguments are passed as a list, so there's no shell word-splitting or quoting minefield.
| Concern | Bash | Python (or Go/Ruby) |
|---|---|---|
| Data structures | Strings + arrays | Lists, dicts, dataclasses |
| Error handling | Exit codes, set -e traps | Exceptions, stack traces |
| Unit tests | None practical | pytest, mocks, fixtures |
| Refactoring safety | Manual, fragile | Tests + type checker |
Where the line is
This isn't "never use bash." Short, linear glue stays in bash. The trigger is length plus branching: once a script pushes past roughly 200 lines and carries meaningful conditionals, loops, and error paths, the cost of bash's missing features exceeds the cost of a rewrite. On a deploy or data-migration path, a test suite is the difference between confidence and crossed fingers.
Power moves
- Keep the CLI ergonomics. Python's
argparse(or Typer/Click) gives you the same command-line feel with validation for free. - Shell out deliberately. Use
subprocess.run([...], check=True)with argument lists, nevershell=Trueon interpolated strings. - Lint the bash you keep. For scripts that stay in shell, run ShellCheck in CI to catch quoting and word-splitting bugs.
- Port incrementally. Wrap the bash in a Python entrypoint, then move logic function by function so you can test as you go.
Resources
- ShellCheck — static analysis for shell scripts
- Google Shell Style Guide — "when to use shell"
- Python
subprocessmodule - Bash pitfalls — Greg's Wiki
Shipping AI or cloud systems? Yeda AI audits and hardens production LLM and deployment pipelines. Talk to us · Read the blog