sh2 logo

Bash vs Python for sysadmin work (and why sh2 makes the debate more interesting)

The script that started small

It started, as these things do, with a one-liner.

systemctl restart nginx && curl -s localhost/health | grep -q '"status":"ok"'

The on-call engineer ran it after a config change. It worked. So they added it to a runbook. Someone else wrapped it in an if with a notification. Then it needed logging. Then someone asked, “Can we add a retry?” Then it landed in CI, and now it had to be reliable on every run, not just most runs.

Six months later, the script was 90 lines. It had set -euo pipefail at the top, a few functions, some tee redirects for logging, and a sudo call that worked locally but hung in CI waiting for a password.

The next person to review it spent an hour. Not because the logic was complex—it wasn’t—but because every line required them to simulate Bash semantics. Does $output get word-split here? Does the tee mask the exit code? Does the cd leak into the next function? Is that ${...} safe to pass to dpkg-query?

At some point, someone on the team suggested: “We should just rewrite this in Python.”

And with that, an ancient debate was reignited.


The three camps

The Bash camp

The Bash camp has a straightforward argument: it’s already there.

Every Linux box has a shell. Every container. Every CI runner. You can SSH into a machine and immediately work. Bash is the universal glue—you compose commands, pipe data, and move on.

For operators, muscle memory matters. grep, awk, sed, jq, curl—these are reflexes. Pipelines are second nature. Typing systemctl status nginx | head -20 is faster than launching a Python REPL.

The Bash camp isn’t naive about the risks. They know about quoting. They’ve read BashPitfalls. They use set -euo pipefail and write defensive scripts. The argument is: for command orchestration and glue, the shell is the native tool.

The Python camp

The Python camp has a different focus: maintainability at scale.

Python has data structures, real functions, exceptions, testability. You can write unit tests. You can refactor without fear. Dependencies are explicit. Logic is explicit. There’s no ambient $? that gets clobbered. No expansion surprises.

For the Python camp, the shell is dangerous by default. Word splitting, glob expansion, set -e exceptions—all of these are failure modes that Python simply doesn’t have. And when you need to run a command, subprocess.run() does the job.

The strongest version of this argument: avoid shell parsing entirely. Don’t let user input near a shell ever. Don’t use Bash’s implicit string handling. Just orchestrate commands as arrays and let Python manage control flow.

The hybrid camp

The hybrid camp says: use both, appropriately.

Bash for quick one-liners, pipelines, streaming text. Python for anything with logic, data, or complexity. If a Bash script grows beyond 20 lines, rewrite it. If a Python script needs a pipeline, shell out.

This is practical, but it leaves open the question: when a Bash script grows, what’s the trigger to rewrite? How do you know it’s “complex enough”? And once you rewrite, you lose the direct connection to the commands you’re orchestrating—Python subprocess calls feel clunky compared to Bash pipelines.


Why the debate never settles

The debate persists because both camps are right about different things.

Strength Bash Python
Availability ⭐⭐⭐ ⭐⭐
Pipelines ⭐⭐⭐
Speed of writing ⭐⭐⭐ ⭐⭐
Readability at scale ⭐⭐⭐
Safety (quoting) ⭐⭐⭐
Data structures ⭐⭐⭐
Testability ⭐⭐⭐
Libraries ⭐⭐⭐

The camps are optimizing for different pain. If your pain is “I need to do something right now on this machine,” Bash wins. If your pain is “I need to ship something that’s maintainable and secure,” Python wins.


Enter sh2: a different framing

sh2 doesn’t try to replace Bash or compete with Python. It targets the seam between them.

The thesis: structured command orchestration. You still run Unix tools. You still compose commands. But the language gives you safety—no word splitting, no glob expansion, no accidental expansion—plus readability: named arguments, scoped blocks, explicit error handling.

Think of it as “Bash with guardrails” or “Python-like syntax for shell scripts that actually run shell commands.”


What sh2 changes

Safe argument passing

In Bash, every variable is a minefield:

rm $file            # Breaks on spaces
rm "$file"          # Works, but fragile if you forget
rm -- "$file"       # Correct, but who remembers?

In sh2:

run("rm", "--", file)

Variables are values, not text to be re-parsed. Spaces don’t split. Stars don’t glob. What you pass is what gets passed.

Strict literals

In Bash, "$FOO" expands. "${Package}" expands. You need single quotes for literals, and the rules are subtle.

In sh2, strings are literal by default:

print("$FOO")                    // Prints: $FOO
run("dpkg-query", "-f", "${Package}\n", "bash")  // Passes literal ${Package}

If you want interpolation, you explicitly ask for it:

let name = "world"
print("Hello " & name)           // Concatenation
print($"Hello {name}")           // Interpolation (v0.1.1+)

No surprises.

Named arguments for readability

Bash flags are cryptic:

sudo -n -u deploy ./deploy.sh

sh2 uses named arguments:

sudo("./deploy.sh", user="deploy", n=true)

user="deploy" is obvious. n=true means non-interactive. Reviewers don’t have to decode flags.

Explicit error flow

Bash’s set -e has exceptions. $? gets clobbered. Pipelines are tricky.

sh2 is explicit:

run("command", allow_fail=true)
if status() != 0 {
    print("Failed with " & status())
}

No ambient error state. You say what you want.

Scoped blocks

In Bash, cd leaks. exec > >(tee ...) is global.

In sh2, scopes are explicit:

with cwd("/tmp") {
    run("ls")
}
// cwd reverts automatically

with redirect { stdout: [file("log"), inherit_stdout()] } {
    run("apt-get", "update")
}
// redirect ends

What happens in the block stays in the block.

Escape hatch

When you genuinely need shell syntax—process substitution, complex multi-tool pipelines—there’s sh("..."). But for many common patterns, structured primitives are available:

# Counting files: structured pipeline (no sh() needed)
let count = capture(
    run("find", ".", "-name", "*.log", "-print")
    | run("wc", "-l")
)

You opt into shell-land only when no structured primitive exists. The trade-off is explicit.


Exhibits: where each tool shines

Exhibit A: Git dirty check

Bash:

if [[ -n $(git status --porcelain) ]]; then echo "Dirty"; fi

Python:

import subprocess
if subprocess.run(["git", "status", "--porcelain"], capture_output=True, text=True).stdout:
    print("Dirty")

sh2:

if capture(run("git", "status", "--porcelain")) != "" {
    print("Dirty")
}

Verdict: Bash is concise but cryptic (-n, [[...]]). Python is verbose. sh2 matches the intent closely.


Exhibit B: Log output while showing it

Bash:

apt-get update 2>&1 | tee -a install.log

Python:

# 15+ lines with Popen, line-by-line iteration, dual writes

sh2:

with redirect { stdout: [file("install.log", append=true), inherit_stdout()], stderr: to_stdout() } {
    run("apt-get", "update")
}

Verdict: sh2’s scoped redirect is cleaner than Bash’s tee (which masks exit codes) and far less verbose than Python.


Exhibit C: Confirm before destruction

Bash:

read -p "Delete /var/cache/*? [y/N] " ans
[[ "$ans" =~ ^[Yy] ]] && rm -rf /var/cache/*

Python:

if input("Delete /var/cache/*? [y/N] ").lower().startswith("y"):
    import shutil
    shutil.rmtree("/var/cache/")

sh2:

if confirm("Delete /var/cache/*?", default=false) {
    run("rm", "-rf", "/var/cache/*")
}

Verdict: sh2’s confirm(default=false) is CI-safe—it doesn’t hang, it fails fast. Bash’s read hangs in non-interactive mode. Python works but requires manual coding.


Exhibit D: Running a container with volume usage

Bash:

docker run -v "$(pwd):/app" -w /app node npm install

sh2:

run("docker", "run", "-v", env.PWD & ":/app", "-w", "/app", "node", "npm", "install")

Verdict: Bash is slightly shorter but $(pwd) quoting is a common trap (path with spaces?). sh2 requires explicit valid concatenation, protecting the mount path.


Exhibit E: Parse JSON from an API

Bash:

version=$(curl -s https://api.example.com | jq -r '.version')

Python:

import urllib.request, json
with urllib.request.urlopen("https://api.example.com") as r:
    version = json.load(r)["version"]

sh2:

let json = capture(run("curl", "-s", "https://api.example.com"))
let version = capture(run("echo", json) | run("jq", "-r", ".version"))

Verdict: Bash + jq is concise for quick work. Python is better when you need to do more with the data. sh2 uses structured pipelines—no shell escape needed.


Exhibit F: Pipeline-heavy text aggregation

Bash:

cat access.log | grep 'GET' | awk '{print $7}' | sort | uniq -c | sort -rn | head -10

Python:

from collections import Counter
with open("access.log") as f:
    paths = [l.split()[6] for l in f if "GET" in l]
for path, c in Counter(paths).most_common(10):
    print(c, path)

sh2:

# sh(...) because: complex multi-tool pipeline with awk field extraction
sh("cat access.log | grep 'GET' | awk '{print $7}' | sort | uniq -c | sort -rn | head -10")

Verdict: Bash pipelines are unbeatable here. Python is fine but less elegant. sh2 defers to sh("...") for dense awk/sed chains.


Exhibit G: The script that grew

A 90-line deploy script needs review. It has:

  • set -euo pipefail
  • sudo with flags
  • tee logging
  • cd into directories
  • A health check loop

In Bash, a reviewer must simulate shell semantics on every line.

In sh2:

  • Arguments are values, not parsed text
  • sudo(... n=true) is readable
  • with redirect replaces global exec
  • with cwd prevents leakage
  • status() makes error flow explicit

The same logic, but the reviewer reads intent instead of decoding semantics.


Honest limits

Where Bash is still best

  • Dense pipelines: No competing with grep | awk | sort | uniq.
  • Process substitution: diff <(cmd1) <(cmd2) has no sh2 equivalent.
  • Job control: bg, fg, &, wait—sh2 doesn’t do this.
  • Interactive work: The shell is the shell.

Where Python is still best

  • Structured data: Dicts, lists, classes.
  • Algorithms: Anything beyond “run commands and check status.”
  • Libraries: HTTP, databases, templating, testing.
  • Long-lived tools: Scripts with tests, documentation, releases.

Where sh2 fits

  • Reviewable command orchestration: When a Bash script grows past the point of easy review.
  • Safe glue: When you still want Unix tools but need safety guarantees.
  • CI/CD scripts: Non-interactive, predictable, reviewable.
  • Privileged automation: Confirmation prompts, explicit sudo options.

Decision rubric

Pick Bash when:

  • One-liner or quick pipeline
  • Interactive exploratory work
  • Dense text processing
  • You’ll throw it away after

Pick sh2/sh2do when:

  • Multi-step command orchestration
  • Script will be reviewed by others
  • Safety matters (quoting, expansion, sudo)
  • CI/CD automation

Pick Python when:

  • Complex logic or data structures
  • Libraries needed (HTTP, JSON parsing, databases)
  • Long-lived tool with tests
  • Heavy computation

Closing thought

The Bash vs Python debate is about trade-offs, not winners. Bash optimizes for speed and universality. Python optimizes for correctness and maintainability.

sh2 doesn’t resolve the debate—it changes the framing. Instead of “use Bash until it’s painful, then rewrite in Python,” you have: “use sh2 when you want the Unix toolbox with safety and reviewability.”

The 90-line deploy script doesn’t have to stay in Bash. It doesn’t have to become Python. It can be something in between—structured enough to review, shell enough to use.

That’s a new option. What you do with it is up to you.


Docs

The GitHub repo is here:
https://github.com/siu-mak/sh2lang

Further Documentation