Bash & the POSIX Shell Module 18 – Choosing tools and interview preparation
When to use Bash, and when to switch to Python
Decide when a shell script fits a task, spot the signs that it should become Python, and call shell tools from Python safely with argument lists.
What you will learn
- Decide whether a task suits a shell script
- Recognise the signs that a script should become a program
- Call shell tools safely from Python after you switch
- Distinguish Bash from zsh, fish and PowerShell when writing scripts
- Wrap a program in a thin Bash script that keeps its arguments and status
Before you start
What you’ll learn · Before you start
What you will learn
- Decide whether a task suits a shell script
- Recognise the signs that a script should become a program
- Call shell tools safely from Python after you switch
- Distinguish Bash from zsh, fish and PowerShell when writing scripts
- Wrap a program in a thin Bash script that keeps its arguments and status
Before you start
On this page
One log summary took 13 lines of Bash and 48 lines of Python here, and the two printed the same eleven lines. Then a line with a dash where its latency belonged went into both: the Bash version counted it as a request of 0 ms and printed a lower mean, while the Python version stopped and named line 25.
Use Bash when a task mostly runs other programs, moves files and checks how each step ended. Switch to Python when the hard part is no longer running programs: nested JSON, decimals, many error cases, state that must last, users on Windows, speed that matters or more than about 100 lines. The sections below show where the line between them falls, from the seven signs to a wrapper that keeps the command people type.
Bash is at its best when it glues programs together
A small site is released in four steps: copy the pages, check that each has a <title>, pack them into an archive and
list what went in. Each step is a program that already exists (cp, grep, tar and sort), so the script only runs them in
order and stops when one reports a problem. This is release.sh, 15 lines:
#!/usr/bin/env bash
# Packs the pages of a small site into dist/site.tar.gz, after checking that every page has a <title>.
set -euo pipefail
rm -rf build dist
mkdir -p build dist
cp -R pages/. build/
untitled=$(grep -L '<title>' build/*.html || true)
if [[ -n $untitled ]]; then
printf 'release: no <title> in %s\n' "$untitled" >&2
exit 1
fi
tar -czf dist/site.tar.gz -C build .
tar -tzf dist/site.tar.gz | sort
echo "release: packed $(find build -name '*.html' | wc -l | tr -d ' ') pages" The session runs it on two good pages, then adds a page without a title and runs it again:
from session import files, run, write
# A glue script: release.sh copies pages, checks them with grep, packs them with tar and lists the archive. It runs
# once on two good pages, then again after a page without a <title> is added; ls shows what the failed run left.
files("release.sh")
write("pages/index.html", "<html><head><title>Home</title></head><body>Welcome</body></html>\n")
write("pages/about.html", "<html><head><title>About</title></head><body>About us</body></html>\n")
run("""
bash release.sh
printf '%s\\n' '<html><body>Prices</body></html>' > pages/prices.html
!bash release.sh
ls dist
""") Output
$ bash release.sh ./ ./about.html ./index.html release: packed 2 pages $ printf '%s\n' '<html><body>Prices</body></html>' > pages/prices.html $ bash release.sh release: no <title> in build/prices.html [exit status 1] $ ls dist
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 glue_session.py
The first run packed two pages and listed the archive. In the second, grep -L named the page without a title, the
script printed it on standard error and ended with exit status: The number from 0 to 255 every command ends with, kept by the shell in $?. 0 means success; Bash uses 126 for found but not executable and 127 for not found. More in “Your first script: shebang, chmod and running it” 1 before tar ran, so
ls dist showed an empty folder. Nothing in the script computes anything: grep decides, tar packs, and Bash checks the
result of each step and stops.
That is the work Google’s shell style guide accepts shell for: “small utilities or simple wrapper scripts” that mostly call other programs and change little data. Typical cases are running commands in order and stopping at the first failure, moving and packing files, short pipelines, the steps of a CI job and the entrypoint script of a container.
Moving glue into a program for its own sake
Wrong: replacing a 15-line script like release.sh with a Python program that calls cp, grep and tar one by one.
Right: keep it in Bash. The program would run the same four tools, with more code around every call and nothing
gained that a reader can see.
So the first question about any task is what it mostly does: when it runs programs and moves files, start in Bash.
Checkpoint
Check: Glue: the work Bash does best
One log summary written twice, and what each did with a bad line
Some jobs sit on the line between glue and a program. A web server writes one line per request, with the client, the
method, the path, the status and the time in milliseconds, such as 203.0.113.7 GET /checkout 200 182. The summary
wanted from 24 such lines: the requests by status class, the three busiest paths, the mean latency and the share of 5xx
answers. Here it is in Bash, where cut, sort and uniq count and awk does the sums with decimals:
#!/usr/bin/env bash
# Summarises an access log of lines "client method path status ms": requests by status class, the three busiest
# paths, the mean latency and the share of 5xx answers. Text tools do the counting; awk does the decimals.
set -euo pipefail
log=${1:?usage: log-summary.sh FILE}
printf 'requests: %d\n' "$(wc -l < "$log")"
cut -d' ' -f4 "$log" | cut -c1 | sort | uniq -c | while read -r n c; do printf '%sxx: %d\n' "$c" "$n"; done
echo 'busiest paths:'
cut -d' ' -f3 "$log" | sort | uniq -c | sort -k1,1nr -k2,2 | head -n 3 |
while read -r n path; do printf ' %s %d\n' "$path" "$n"; done
awk '{ ms += $5; if ($4 >= 500) err++ }
END { printf "mean latency: %.1f ms\n5xx share: %.1f %%\n", ms / NR, 100 * err / NR }' "$log" And here it is in Python, which reads the file once and checks each line before it counts it:
"""Summarises an access log of lines "client method path status ms": requests by status class, the three busiest
paths, the mean latency and the share of 5xx answers, exactly as log-summary.sh prints them. A line that does not
have five fields with a numeric status and latency stops the program with its line number."""
import sys
from collections import Counter
def main(argv):
if len(argv) != 2:
print("usage: log_summary.py FILE", file=sys.stderr)
return 2
name = argv[1]
classes, paths = Counter(), Counter()
total = latency = errors = 0
try:
with open(name, encoding="utf-8") as log:
for number, line in enumerate(log, start=1):
fields = line.split()
if len(fields) != 5 or not fields[3].isdigit() or not fields[4].isdigit():
print(f"{name}: line {number}: expected 'client method path status ms': {line.strip()}",
file=sys.stderr)
return 1
_client, _method, path, status, ms = fields
total += 1
classes[status[0] + "xx"] += 1
paths[path] += 1
latency += int(ms)
errors += status.startswith("5")
except OSError as error:
print(f"{name}: {error.strerror}", file=sys.stderr)
return 1
if total == 0:
print(f"{name}: no requests", file=sys.stderr)
return 1
print(f"requests: {total}")
for status_class in sorted(classes):
print(f"{status_class}: {classes[status_class]}")
print("busiest paths:")
for path, n in sorted(paths.items(), key=lambda item: (-item[1], item[0]))[:3]:
print(f" {path} {n}")
print(f"mean latency: {latency / total:.1f} ms")
print(f"5xx share: {100 * errors / total:.1f} %")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv)) The session runs both on the log and compares their output, then gives both a copy whose last line has - where the
latency belongs:
from session import files, run
# The same summary twice, on one log: log-summary.sh (text tools, with awk for the decimals) and log_summary.py. Then
# both read a copy of the log with one bad line at the end, whose latency is "-" instead of a number, and one awk rule
# shows the check the Bash version would need.
files("access.log", "log-summary.sh", "log_summary.py")
run("""
bash log-summary.sh access.log
bash log-summary.sh access.log > from-bash.txt
python3 log_summary.py access.log > from-python.txt
diff from-bash.txt from-python.txt && echo 'same output'
wc -l log-summary.sh log_summary.py
cp access.log bad.log
echo '198.51.100.9 GET /search 200 -' >> bad.log
bash log-summary.sh bad.log
!python3 log_summary.py bad.log
!awk '$5 !~ /^[0-9]+$/ { printf "bad.log: line %d: latency %s is not a number\\n", NR, $5; exit 1 }' bad.log
""") Output
$ bash log-summary.sh access.log
requests: 24
2xx: 18
3xx: 1
4xx: 2
5xx: 3
busiest paths:
/search 8
/checkout 7
/products 6
mean latency: 254.3 ms
5xx share: 12.5 %
$ bash log-summary.sh access.log > from-bash.txt
$ python3 log_summary.py access.log > from-python.txt
$ diff from-bash.txt from-python.txt && echo 'same output'
same output
$ wc -l log-summary.sh log_summary.py
13 log-summary.sh
48 log_summary.py
61 total
$ cp access.log bad.log
$ echo '198.51.100.9 GET /search 200 -' >> bad.log
$ bash log-summary.sh bad.log
requests: 25
2xx: 19
3xx: 1
4xx: 2
5xx: 3
busiest paths:
/search 9
/checkout 7
/products 6
mean latency: 244.1 ms
5xx share: 12.0 %
$ python3 log_summary.py bad.log
bad.log: line 25: expected 'client method path status ms': 198.51.100.9 GET /search 200 -
[exit status 1]
$ awk '$5 !~ /^[0-9]+$/ { printf "bad.log: line %d: latency %s is not a number\n", NR, $5; exit 1 }' bad.log
bad.log: line 25: latency - is not a number
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 log_summary_session.py
On the good log the two printed the same bytes: diff found no difference, with 24 requests, a mean of 254.3 ms and a
5xx share of 12.5 %. The Bash script is 13 lines and the Python program 48, and four checks take 14 of the Python
lines: the number of arguments, the shape of every line (five fields, numbers in the last two), a missing file and an
empty log, each with its own message.
The bad log shows what those checks buy. The Bash version printed 25 requests, a mean of 244.1 ms and a 5xx share of
12.0 %, and ended with status 0. POSIX awk turns a string into a number the way C’s atof does, so the - became 0 and
a broken line counted as a very fast request. set -euo pipefail did not stop it, because no command failed. The
Python version stopped at line 25, printed the line, and ended with status 1.
One awk rule closes this gap inside the script: the last command of the session checks that the fifth field is made of digits and stops with status 1. A script that needs many such checks shows one of the signs of the next section.
Trusting strict mode to catch bad data
Wrong: expecting set -euo pipefail to stop a script when its input is malformed. Right: check the data yourself, as
the awk rule or the Python program does. Strict mode reacts to commands that fail, and awk adding a 0 does not fail.
Checkpoint
Check: One job in Bash and in Python
Seven signs that a script has outgrown Bash
The summary needed decimals and careful input checks, two of the reasons people move a script to another language. Step through all seven; each step says what the sessions of this lesson showed or what a cited page says:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- Nested data. jq answered two flat questions about order.json in one line each: the item names, and the total 77.5. When a job needs joins, groups or several files of nested JSON, each jq line grows, and a program with real data structures is easier to read.
- Decimals. $(( 7 / 2 )) printed 3 and $(( 0.1 + 0.2 )) failed with an arithmetic syntax error, because Bash counts in whole numbers. awk and Python have floating point, and Python's decimal module printed 0.3 exactly.
- Error handling. With set -e, a function that ran as an if condition went on after false and the if printed "check passed". The manual lists such places where set -e does nothing, and a script with many checks must keep all of them in mind.
- Long-lived state. A job that runs for hours and must remember what it did, such as a retry queue, has to keep that memory in files or variables that vanish when the shell ends. A program keeps it in data structures and can save it on purpose.
- Windows users. On Windows, Bash comes only with an add-on that brings a Linux or Unix-like environment: the Windows Subsystem for Linux, or the Bash that comes with Git for Windows or Cygwin. A tool for people on Windows laptops is easier to hand over in Python, which installs and runs on Windows itself.
- Over 100 lines. Google's shell style guide says that a script of more than 100 lines, or one with control flow that is not straightforward, should be rewritten in a more structured language now, because scripts grow.
- Speed matters. The same guide says to use something other than shell when performance matters. Each external program a script runs is a new process, so a loop that starts one per line pays that cost on every line.
- Count the signs. One sign alone is often fixed inside the script: awk for a mean, one jq line for a field. When two or more hold at once, move the logic into a program and keep Bash for the glue around it.
The next session tries three of them: Bash’s whole-number arithmetic, floating point in awk and Python, Python’s decimal
module, two flat questions about an order in JSON answered by jq, and set -e inside a function that runs as the
condition of an if:
from session import run, write
# Three of the warning signs, tried: Bash arithmetic has whole numbers only, a flat JSON question is one jq line, and
# set -e stops nothing inside a function that runs as the condition of an if.
write("order.json", '{"order": "A-1042", "items": [{"sku": "pen", "qty": 3, "price": 12.5}, {"sku": "ink", "qty": 1, "price": 40}]}\n')
run("""
# Bash arithmetic works on whole numbers only
echo $(( 7 / 2 ))
!echo $(( 0.1 + 0.2 ))
# awk and Python use binary floating-point numbers; Python's decimal module keeps decimal fractions exact
awk 'BEGIN { print 0.1 + 0.2; printf "%.17f\\n", 0.1 + 0.2 }'
python3 -c 'print(0.1 + 0.2)'
python3 -c 'from decimal import Decimal; print(Decimal("0.1") + Decimal("0.2"))'
# a flat question about JSON stays one jq line
jq -r '.items[].sku' order.json
jq '[.items[] | .qty * .price] | add' order.json
# set -e is switched off inside a function that runs as an if condition
bash -c 'set -e; check() { false; echo "check went on after false"; }; if check; then echo "check passed"; fi'
""") Output
# Bash arithmetic works on whole numbers only
$ echo $(( 7 / 2 ))
3
$ echo $(( 0.1 + 0.2 ))
bash: 0.1 + 0.2 : arithmetic syntax error: invalid arithmetic operator (error token is ".1 + 0.2 ")
[exit status 1]
# awk and Python use binary floating-point numbers; Python's decimal module keeps decimal fractions exact
$ awk 'BEGIN { print 0.1 + 0.2; printf "%.17f\n", 0.1 + 0.2 }'
0.3
0.30000000000000004
$ python3 -c 'print(0.1 + 0.2)'
0.30000000000000004
$ python3 -c 'from decimal import Decimal; print(Decimal("0.1") + Decimal("0.2"))'
0.3
# a flat question about JSON stays one jq line
$ jq -r '.items[].sku' order.json
pen
ink
$ jq '[.items[] | .qty * .price] | add' order.json
77.5
# set -e is switched off inside a function that runs as an if condition
$ bash -c 'set -e; check() { false; echo "check went on after false"; }; if check; then echo "check passed"; fi'
check went on after false
check passed
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 warning_signs_session.py
$(( 7 / 2 )) printed 3, and $(( 0.1 + 0.2 )) stopped with an arithmetic syntax error, because the Bash manual
defines its arithmetic on fixed-width integers. awk printed 0.3, but only because it prints six significant digits by
default: printed with 17 decimal places, the same sum is 0.30000000000000004, which Python printed at once. That is
floating point: A way of storing numbers with fractions in binary. Most decimal fractions, such as 0.1, have no exact binary form and are stored as the nearest value, so a sum such as 0.1 + 0.2 can print as 0.30000000000000004.: 0.1 has no exact binary form. Python’s decimal module printed 0.3,
because it keeps decimal fractions exact. You can try both Python lines in the
Python online compiler.
jq answered the two flat questions in one line each: the item names, and a total of 77.5. JSON alone is not the sign;
questions that need several jq lines, joins between files or deep nesting are. The last line is the error-handling
sign: with set -e on, check went on after false, printed its message, and the if took its then branch. The
manual lists the places where set -e is ignored, and the errexit lesson
tries each one. A script that is right only for a reader who knows them all is hard to change.
The other signs come from the guide and from the platforms. Google’s guide asks for a rewrite in a more structured language once a script passes 100 lines or its control flow stops being straightforward, and it says to use something other than shell when performance matters: every external program a script runs is a new process, a cost the performance lesson measures. A job that keeps state for hours has only files and variables to keep it in. On Windows, Bash comes only with an add-on that brings a Linux or Unix-like environment, such as the Windows Subsystem for Linux, Git for Windows or Cygwin, while Python installs and runs on Windows itself.
Key idea
One sign alone can often be handled inside the script, as awk handled the mean; two or more at once mean the logic belongs in a program, with Bash kept for the glue.
Checkpoint
Check: Signs that a script has outgrown Bash
Choosing a tool for a task with four questions
Most tasks are decided by four questions: what the task mostly does, whether Bash may be missing, whether it will grow, and whether Windows users run it. Answer them for a task of your own in the table below. Each rule that rules a tool out says why, with the numbers of this lesson’s sessions:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Tool | Runs other programs | Sums with decimals | Nested data | Errors and state | Runs on Windows |
|---|---|---|---|---|---|
| Bash Best at: running and checking programs | Good release.sh copied, checked, packed and listed pages in 15 lines | Poor whole numbers only: $(( 0.1 + 0.2 )) is a syntax error | Mixed fine while one jq line answers the question | Mixed set -e went on inside a function run as an if condition | Poor on Windows it needs an add-on such as WSL or Git for Windows |
| POSIX sh Best at: the same glue on any sh | Good the same glue, run by dash or any other POSIX sh | Poor whole numbers only, as in Bash | Poor no arrays at all; one jq line at most | Mixed set -e with the same exceptions as Bash | Poor on Windows it needs the same kind of add-on as Bash |
| awk Best at: columns of text in one pass | Poor reads lines and fields; starting programs is not its job | Good one pass with decimals: a mean latency of 254.3 ms | Poor fields of a line, not JSON objects | Poor read a latency of "-" as 0 and printed 244.1 ms | Poor on Windows it comes with an add-on such as WSL, as Bash does |
| Python Best at: data, decimals and errors | Mixed subprocess.run with a list: more words than one shell line | Good floats, and the decimal module when 0.1 must stay 0.1 | Good json, dicts and lists to any depth | Good stopped at line 25 and named the bad line, status 1 | Good installs and runs on Windows itself |
How the answers decide
- “What does the task mostly do?” Runs programs, moves files: rules out awk. awk reads text in one pass; running programs in order and checking each status is the job Bash and sh were made for.
- “What does the task mostly do?” Sums or counts text columns: adds a caveat to Bash and POSIX sh. Bash and sh count whole numbers only, so a sum or mean with decimals needs awk inside the script anyway.
- “What does the task mostly do?” Nested data or exact decimals: rules out Bash, POSIX sh and awk. These read lines and whole numbers; nested JSON beyond one jq line or exact decimals need Python's json and decimal modules.
- “Must it run where Bash may be missing, such as a minimal container image?” Yes, Bash may be missing: rules out Bash and adds a caveat to Python. A minimal image may hold only a POSIX sh, so the script must not need Bash, and Python works only if the image installs it.
- “Will it pass about 100 lines, keep state for long, or need careful error handling?” Yes, it will grow: rules out POSIX sh and awk and adds a caveat to Bash. Google's shell style guide says to rewrite a script of more than 100 lines, or with non-straightforward control flow, in a more structured language.
- “Must it run on Windows laptops as well?” Yes, on Windows too: rules out Bash and POSIX sh and adds a caveat to awk. On Windows these come only with an add-on that brings a Linux or Unix-like environment, such as WSL or Git for Windows; Python runs on Windows itself.
The same questions settle these ten tasks:
| Task | Tool | Why |
|---|---|---|
| Run the tests, build an image and push it, as one CI step | Bash | Three programs in order, stop at the first failure |
Set two variables and start a server in an image that has only /bin/sh |
POSIX sh | Bash may be missing there |
| Print every TODO line of a repository with its file and line number | Bash | One grep -rn command |
| Count the requests per status class in a web log | Bash | cut, sort and uniq -c count whole numbers |
| The mean response time per path of a 2 GB log, in one pass | awk | One pass, with decimals |
| Compress last week’s logs and copy them to a backup host | Bash | gzip, a copy tool and a status check |
| Wait until a database accepts connections, at most 30 tries | Bash | A short loop around one client command |
| Merge two JSON exports by user id and list the changed plans | Python | Nested data and a join |
| A tool that colleagues run on Windows laptops with no Bash installed | Python | Installs and runs on Windows itself |
| A nightly job that remembers uploaded files and retries failures across runs | Python | Lasting state and many error cases |
A task often mixes kinds of work: the log summary counted with a pipeline and averaged with awk inside one Bash script, which stays fine while each part is small.
Decide by the hardest part
If the hardest part of a task is running programs in the right order, start in Bash. If it is the data, start in Python and call the few programs it needs.
Checkpoint
Check: Choosing the tool for a task
After the switch, call tools with an argument list
A Python program still runs other programs: grep for a quick count, tar, git, or a vendor’s command-line tool. Python’s
subprocess module can do that in two ways, and they differ in who reads the arguments. The first program below builds
one string and passes shell=True; the second passes a list:
"""Counts ERROR lines with grep, with two slips: the command is one string run by /bin/sh (shell=True), built from the
file name, and check=True treats grep's status 1 ("no line matched") as a failure."""
import subprocess
import sys
for name in sys.argv[1:]:
try:
result = subprocess.run(f"grep -c ERROR {name}", shell=True, check=True, capture_output=True, text=True)
print(f"{name}: {result.stdout.strip()}")
except subprocess.CalledProcessError as error:
print(f"{name}: failed with status {error.returncode}") """Counts ERROR lines with grep, safely: the arguments go to grep as a list, so no shell reads the file name, and the
status is read the way grep defines it: 0 lines matched, 1 no line matched, 2 or more an error."""
import subprocess
import sys
def count_errors(name):
result = subprocess.run(["grep", "-c", "--", "ERROR", name], capture_output=True, text=True)
if result.returncode > 1:
raise RuntimeError(result.stderr.strip())
return int(result.stdout)
status = 0
for name in sys.argv[1:]:
try:
print(f"{name}: {count_errors(name)}")
except RuntimeError as error:
print(f"{name}: {error}", file=sys.stderr)
status = 1
sys.exit(status) Both count the ERROR lines of app.log. The session also gives them a log with no ERROR line, and a file whose name is
notes; echo injected.log, which Linux and macOS accept as a file name:
from session import files, run, write
# Three logs: one with three ERROR lines, one with none, and one whose file name holds a semicolon. weak_count.py runs
# grep through /bin/sh with check=True; count_errors.py passes a list and reads grep's status itself.
files("weak_count.py", "count_errors.py")
write("app.log", "INFO start\nERROR disk full\nERROR disk full\nINFO retry\nERROR timeout\n")
write("clean.log", "INFO start\nINFO done\n")
write("notes; echo injected.log", "ERROR one line\n")
run("""
ls
python3 weak_count.py app.log clean.log 'notes; echo injected.log'
python3 count_errors.py app.log clean.log 'notes; echo injected.log'
!python3 count_errors.py missing.log
""") Output
$ ls app.log clean.log count_errors.py notes; echo injected.log weak_count.py $ python3 weak_count.py app.log clean.log 'notes; echo injected.log' app.log: 3 clean.log: failed with status 1 notes; echo injected.log: injected.log $ python3 count_errors.py app.log clean.log 'notes; echo injected.log' app.log: 3 clean.log: 0 notes; echo injected.log: 1 $ python3 count_errors.py missing.log missing.log: grep: missing.log: No such file or directory [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 subprocess_session.py
With shell=True, Python starts /bin/sh with the whole string, as the subprocess documentation says for POSIX
systems, and the shell split it at the semicolon. grep looked for a file named notes and failed, and then the shell ran
echo injected.log, a command that came from a file name. The script printed injected.log as the count. With a name
that a user typed, that is command injection: A bug where user text is read as shell: a value such as x; touch MARKER arrives through eval, sh -c or unquoted expansion and runs its own commands. The fix is to pass the value as one argument, never splice it into a command string. More in “Injection, eval and untrusted input”. The list version gave grep five
strings as its argument list: The separate strings a program receives as its arguments. Passing a list to Python's subprocess.run hands each string to the program as one argument, with no shell to split it or read its special characters., with no shell between them, so the semicolon stayed a
character of the name and grep counted 1. Step through both calls:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- A list goes straight to grep. count_errors.py calls subprocess.run(["grep", "-c", "--", "ERROR", name]). No shell starts: grep gets five arguments, and the semicolon in the name is only a character of the file name.
- grep counts one line. grep finds the one ERROR line of the file and exits with status 0. The script printed "notes; echo injected.log: 1".
- One string goes to /bin/sh. weak_count.py builds f"grep -c ERROR {name}" and passes it with shell=True. Python starts /bin/sh with that string, so the shell decides where each word and each command ends.
- The shell splits at the ;. The shell reads the text as two commands. grep -c ERROR notes looks for a file named notes, finds none, and fails with status 2.
- The second command runs. Then the shell runs echo injected.log, a command that came from a file name, and exits with its status 0. check=True sees success, and the script printed "notes; echo injected.log: injected.log".
The other slip was check=True. POSIX defines grep’s statuses: 0 when a line was selected, 1 when none was, and more
than 1 for an error. So status 1 on clean.log was a correct count of 0, and check=True turned it into
failed with status 1. count_errors.py reads the status itself, treats 2 and above as errors and prints grep’s own
message, as the run on missing.log shows. The -- before the pattern ends grep’s options, so a file name that starts
with a dash is still read as a file.
An f-string with shell=True
Wrong: subprocess.run(f"grep -c ERROR {name}", shell=True). Right:
subprocess.run(["grep", "-c", "--", "ERROR", name]). The string lets /bin/sh read the name as shell code; the
list hands it to grep as one argument.
The Python track’s subprocess lesson goes on to output, timeouts and errors, and the
injection lesson shows the same mistake inside Bash, with eval.
Checkpoint
Check: Calling tools from Python safely
zsh, fish and PowerShell are other languages, not other Bashes
Your terminal may not start Bash at all. Apple’s support page says that since macOS 10.15, new user accounts get zsh: The Z shell, the default login and interactive shell for new macOS accounts since macOS 10.15. It reads much Bash syntax but differs in places: by default it does not split unquoted variables, and it numbers arrays from 1. as their login and interactive shell, and it calls zsh highly compatible with sh and mostly compatible with Bash. Mostly is the word to remember. The session runs the same lines in Bash 5.3.20 and in zsh 5.9, then sorts three numbers with sort and with PowerShell: Microsoft's shell and scripting language for Windows, Linux and macOS. Its commands pass .NET objects along the pipeline instead of lines of text, so it sorts numbers as numbers. 7.6.6:
from session import run
# The same lines in Bash 5.3.20 and in zsh 5.9 (zsh -f reads no startup files), then sorting in Bash's sort and in
# PowerShell 7.6.6, which passes objects rather than text from one command to the next.
run("""
zsh --version
files="a.txt b.txt"; for f in $files; do echo "[$f]"; done
zsh -fc 'files="a.txt b.txt"; for f in $files; do echo "[$f]"; done'
list=(x y z); echo "${list[1]}"
zsh -fc 'list=(x y z); echo $list[1]'
pwsh --version
printf '%s\\n' 3 10 2 | sort
pwsh -NoProfile -NonInteractive -Command "'3', '10', '2' | Sort-Object"
pwsh -NoProfile -NonInteractive -Command "3, 10, 2 | Sort-Object"
""") Output
$ zsh --version
zsh 5.9 (arm64-apple-darwin25.0)
$ files="a.txt b.txt"; for f in $files; do echo "[$f]"; done
[a.txt]
[b.txt]
$ zsh -fc 'files="a.txt b.txt"; for f in $files; do echo "[$f]"; done'
[a.txt b.txt]
$ list=(x y z); echo "${list[1]}"
y
$ zsh -fc 'list=(x y z); echo $list[1]'
x
$ pwsh --version
PowerShell 7.6.6
$ printf '%s\n' 3 10 2 | sort
10
2
3
$ pwsh -NoProfile -NonInteractive -Command "'3', '10', '2' | Sort-Object"
10
2
3
$ pwsh -NoProfile -NonInteractive -Command "3, 10, 2 | Sort-Object"
2
3
10
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 shells_session.py
zsh did not split $files at the space, so its loop ran once with a.txt b.txt where Bash’s ran twice. The zsh manual
explains both differences: splitting unquoted parameters needs its SH_WORD_SPLIT option, and arrays count from 0 only
with KSH_ARRAYS, so $list[1] was x in zsh while ${list[1]} was y in Bash. A script that relies on either behaviour
gives another answer in the other shell, without any error.
fish: The friendly interactive shell, whose scripting language is intentionally not POSIX-compatible: it has no word splitting, no [[ and no ${var:-default}, so Bash and sh scripts do not run in it unchanged. goes further. Its guide for Bash users says it is intentionally not
POSIX-compatible: it does no word splitting and has no [[ and no ${var:-default}. PowerShell, Microsoft’s shell
for Windows, Linux and macOS, passes .NET objects along its pipeline instead of lines of text. That is why it sorted
the numbers 3, 10, 2 as 2, 3, 10, while the strings ‘3’, ‘10’, ‘2’ came out 10, 2, 3, the order Bash’s sort gave,
because sort compares text.
The table adds dash, which Ubuntu runs as /usr/bin/sh, and says where you meet each shell:
| Shell | Where you meet it | Do Bash scripts run in it unchanged? |
|---|---|---|
| zsh | New macOS accounts since 10.15 | Many do; splitting and arrays differ |
| fish | Installed by choice | No: another language |
| PowerShell | Windows, also Linux and macOS | No: objects instead of text |
| dash | /usr/bin/sh on Ubuntu |
Only POSIX sh, without bashisms |
Use any of them at the prompt. For a script that other people run, the first line decides the shell, not anyone’s login
shell: name Bash with #!/usr/bin/env bash, or keep to POSIX sh as the
POSIX sh lesson shows.
Run a Bash script with Bash
On a Mac with zsh, start a script as ./script.sh, so its first line chooses Bash, or as bash script.sh. Never
source it in zsh or paste it at the zsh prompt, where zsh’s own rules read every line.
Checkpoint
Check: zsh, fish and PowerShell
When in doubt, keep a thin Bash wrapper around a program
Moving the logic into a program does not mean asking everyone to type a new command. A wrapper script: A short script whose only job is to start another program under a familiar name: it finds the program, checks what it needs and hands over, often with exec and "$@", so the arguments and the exit status pass through unchanged. keeps the old name, finds the program, checks that what it needs is installed and hands over. This one is 7 lines and starts the log summary of the second section:
#!/usr/bin/env bash
# A thin wrapper: it finds the program next to itself, checks that Python is there, and hands over with exec, so the
# arguments arrive unchanged and the program's exit status becomes the wrapper's.
set -euo pipefail
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
command -v python3 > /dev/null || { echo 'summary: python3 is not installed' >&2; exit 127; }
exec python3 "$here/log_summary.py" "$@" The session installs it as summary, calls it from another folder with a file name that holds a space, with no file
and with a missing file, and then checks that exec keeps one process:
from session import files, run
# The wrapper summary.sh, copied in as summary, runs log_summary.py with exec. It is called from another folder, with
# a file name that holds a space, with no file at all and with a missing file, and once more to show that exec leaves
# one process: the shell's PID becomes Python's.
files("summary.sh=summary", "log_summary.py", "access.log=my logs/access.log")
run("""
chmod +x summary
mkdir elsewhere && cd elsewhere
../summary '../my logs/access.log'
!../summary
!../summary missing.log
cd ..
bash -c 'export WRAPPER_PID=$$; exec python3 -c "import os; print(os.getpid() == int(os.environ[\\"WRAPPER_PID\\"]))"'
""") Output
$ chmod +x summary $ mkdir elsewhere && cd elsewhere $ ../summary '../my logs/access.log' requests: 24 2xx: 18 3xx: 1 4xx: 2 5xx: 3 busiest paths: /search 8 /checkout 7 /products 6 mean latency: 254.3 ms 5xx share: 12.5 % $ ../summary usage: log_summary.py FILE [exit status 2] $ ../summary missing.log missing.log: No such file or directory [exit status 1] $ cd .. $ bash -c 'export WRAPPER_PID=$$; exec python3 -c "import os; print(os.getpid() == int(os.environ[\"WRAPPER_PID\"]))"' True
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 wrapper_session.py
"$@" passed ../my logs/access.log as one argument, space included, and the summary printed the same eleven lines as
before. With no file, the usage line and status 2 came from Python, and with a missing file, its message and status 1.
Bash added nothing to either because of the last line: the Bash manual says that exec with a command replaces the
shell without creating a new process. The last command of the session printed True, because the process ID before
exec was the process ID of Python afterwards. Step through one call:
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- One argument arrives. The terminal starts ../summary with one argument, ../my logs/access.log. The quotes typed around it keep the space inside the name.
- The wrapper checks first. summary turns on set -euo pipefail, finds the folder it lives in, and checks with command -v that python3 exists. Without it, the wrapper would say so and exit with 127, the status for a command that was not found.
- exec hands over. exec python3 "$here/log_summary.py" "$@" replaces the Bash process with Python. "$@" passes the name with its space as one argument, and the process stays the same: the session printed True when it compared the two process IDs.
- The status comes straight back. Called with no file, log_summary.py printed its usage line and exited with status 2, and the terminal saw 2. No Bash line runs after exec, so nothing can lose the status or change it.
This is the plan that helps most when you are unsure: keep the name people type and a wrapper of a few lines in Bash, and move the logic into a program with tests. The people and the scheduled jobs that call the command see no change, and the program can grow as far as it needs.
Key idea
A thin wrapper that checks its tools and ends with exec program "$@" keeps the command, the arguments and the exit
status exactly as the program gives them.
Checkpoint
Check: A thin wrapper around a program
Practise: pick the tool, then defend it
Each question names a task or a line from the sessions above. Choose the tool or the result, then read why each answer is right or what it misses:
Practice that adapts to you
Practise this lesson
18 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): when is a shell script the right tool, and what are the signs that it should become a program?
A shell script is the right tool when the work is mostly running other programs: commands in order with a stop at the first failure, moving and packing files, short pipelines, CI steps and small wrappers. The signs that it should become a program are nested data beyond one jq line, decimals (Bash arithmetic has whole numbers only), error handling with many cases, state that lasts for hours, users on Windows, speed that matters, and more than about 100 lines, the limit Google’s shell style guide sets. One sign can often stay in the script, as awk can take a mean; when two or more hold, move the logic into Python and keep a thin Bash wrapper.
Warm-up (2–5 years): why does subprocess.run take a list of arguments, and when is shell=True a problem?
A list goes straight to the program: each string becomes one argument and no shell reads it, so spaces, semicolons and
$( ) stay plain characters. With shell=True, Python starts /bin/sh with one string and the shell decides where
words and commands end; in the recorded run a file named notes; echo injected.log made it run echo as a second
command. Built from user input, such a string is command injection. Pass a list, put -- before values that might
start with a dash, and read each tool’s statuses yourself: grep’s 1 means no match, which check=True treats as a
failure.
Two harder questions go past this page: planning the move of a long release script into Python so that no release breaks, and porting a compressed-log pipeline into a Python service. Settle your first step before you open each one:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to staff
A 420-line Bash release script reads a JSON manifest with grep and sed, retries uploads in sleep loops, and runs by hand and nightly. The team has decided to move it to Python. Plan the migration so that no release breaks: what moves first, what stays in Bash, and how you prove each step.
-
Mid to staff
A report runs zcat logs/*.gz | grep ERROR | sort | uniq -c | sort -rn | head -n 5 on a server. It moves into a Python service that reads millions of lines. Write the Python without shell=True, and say what the shell was doing for you that your code must now do itself.
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What kind of work is Bash best at?
Running other programs: commands in order with a stop at the first failure, moving and packing files, short pipelines, CI steps and small wrappers.
-
What did writing the same log summary in Bash and in Python show?
The Bash version took 13 lines and the Python one 48, 14 of them checks, but only Python stopped at the bad line instead of quietly averaging in a 0.
-
Which signs say a script has outgrown Bash?
Nested data, decimals, many error cases, lasting state, Windows users, more than about 100 lines, and speed. One sign can often be handled in place; two or more call for a program.
-
Which four questions decide whether a task belongs in Bash?
What the task mostly does, whether Bash may be missing where it runs, whether it will grow, and whether Windows users will run it.
-
How should a Python program call command-line tools after a script is rewritten?
Pass subprocess.run a list of arguments and read each tool's status yourself. One string with shell=True let a file name run a command in the lesson.
-
Can a script for other people be written in zsh, fish or PowerShell syntax?
They are other languages, not other Bashes, so scripts meant for others are written in Bash or POSIX sh.
-
What does a thin Bash wrapper around a program do?
It checks the tools it needs and ends with exec program "$@", so the command, its arguments and its exit status stay exactly as the program gives them.
Key takeaways
- Bash is at its best gluing programs together:
release.shran cp, grep and tar in order and stopped with status 1 at the first page without a title. - The same log summary took 13 lines of Bash and 48 of Python, 14 of them checks, and only Python stopped at the bad line instead of averaging in a 0.
- Seven signs say a script has outgrown Bash: nested data, decimals, many error cases, lasting state, Windows users, more than about 100 lines, and speed; one can be handled in place, two or more call for a program.
- Four questions decide most tasks: what it mostly does, whether Bash may be missing, whether it will grow and whether Windows users run it.
- From Python, pass
subprocess.runa list and read each tool’s statuses yourself; one string withshell=Truelet a file name run a command. - zsh, fish and PowerShell are other languages, so scripts for others are Bash or POSIX sh, and a thin wrapper ending
in
execkeeps the command when the logic moves into a program.
Terms in this lesson
- argument list
- The separate strings a program receives as its arguments. Passing a list to Python's subprocess.run hands each string to the program as one argument, with no shell to split it or read its special characters.
- fish
- The friendly interactive shell, whose scripting language is intentionally not POSIX-compatible: it has no word splitting, no [[ and no ${var:-default}, so Bash and sh scripts do not run in it unchanged.
- floating point
- A way of storing numbers with fractions in binary. Most decimal fractions, such as 0.1, have no exact binary form and are stored as the nearest value, so a sum such as 0.1 + 0.2 can print as 0.30000000000000004.
- PowerShell
- Microsoft's shell and scripting language for Windows, Linux and macOS. Its commands pass .NET objects along the pipeline instead of lines of text, so it sorts numbers as numbers.
- wrapper script
- A short script whose only job is to start another program under a familiar name: it finds the program, checks what it needs and hands over, often with exec and "$@", so the arguments and the exit status pass through unchanged.
- zsh
- The Z shell, the default login and interactive shell for new macOS accounts since macOS 10.15. It reads much Bash syntax but differs in places: by default it does not split unquoted variables, and it numbers arrays from 1.
References
- Google Shell Style Guide: When to use Shell (Google)
- subprocess: Subprocess management (Python Software Foundation)
- subprocess: Security Considerations (Python Software Foundation)
- shlex: Simple lexical analysis (Python Software Foundation)
- Floating-Point Arithmetic: Issues and Limitations (Python Software Foundation)
- decimal: Decimal fixed-point and floating-point arithmetic (Python Software Foundation)
- Using Python on Windows (Python Software Foundation)
- Bash Reference Manual: Shell Arithmetic (Chet Ramey (GNU Bash maintainer))
- Bash Reference Manual: The Set Builtin (Chet Ramey (GNU Bash maintainer))
- Bash Reference Manual: Bourne Shell Builtins (Chet Ramey (GNU Bash maintainer))
- POSIX.1-2024: awk (The Open Group and IEEE)
- POSIX.1-2024: grep (The Open Group and IEEE)
- jq 1.8 Manual (jq project)
- Use zsh as the default shell on Mac (Apple)
- zsh manual: Options (The Zsh Development Group)
- fish for bash users (The fish-shell project)
- What is PowerShell? (Microsoft)
- What is the Windows Subsystem for Linux? (Microsoft)
- Git for Windows (Git for Windows project)
- Git for Windows release notes (Git for Windows project)
- Cygwin (The Cygwin project)
- Cygwin package summary: bash (The Cygwin project)
- Ubuntu 24.04 package dash: list of files (Canonical (Ubuntu))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress