Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 20 minutes
  • Examples run with Bash 5.3.20, dash 0.5.13.5, shellcheck 0.11.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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

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:

release.sh: copy, check, pack and list the pages of a site Shell · release.sh
#!/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:

A glue script on good pages and on a page without a title Python · glue_session.py
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

  1. Question 1 of 2 Which job does Google's shell style guide call an acceptable use of shell?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A script that mostly calls other programs and changes little data

    The guide's rule of thumb: shell is fine when you mostly call other utilities and change little data. release.sh ran cp, grep, tar and sort and computed nothing itself.

  2. Question 2 of 2 In the session, release.sh met a page without a <title>. What did ls dist print afterwards?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Nothing: the script stopped with status 1 before tar ran

    The order matters: clear and make the folders, copy, check, then pack. A failed check ends the script before the pack step, so dist stays empty.

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:

log-summary.sh: the summary with text tools Shell · log-summary.sh
#!/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:

log_summary.py: the same summary, with every line checked Python · log_summary.py
"""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:

The same summary in Bash and in Python, on a good log and a bad one Python · log_summary_session.py
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

  1. Question 1 of 2 A line ending in - instead of a latency is added to the 24-line log. What does log-summary.sh print as the mean latency?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 244.1 ms, because awk read the - as 0 and divided by 25

    The session printed 25 requests, a mean of 244.1 ms and a 5xx share of 12.0 % with status 0: the bad line became a request of 0 ms.

  2. Question 2 of 2 log_summary.py is 48 lines and log-summary.sh 13, and on the good log both printed the same bytes. What do 14 of the Python lines do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: They check the input and stop with a message: the arguments, every line, a missing file and an empty log

    The shell gets the usual case cheaply because each tool does one job; the program pays in lines for checks that name the bad line and stop.

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:

Interactive · step by step Seven checks before a script grows Step through the checks; each one says what this lesson's sessions showed or what a cited page says.
Seven checks before a script grows, step 8 of 8. Showing Nested data, Decimals, Error handling, Long-lived state, Windows users, Over 100 lines, Speed matters.Nested datamore than one jq lineDecimalsBash has whole numbersError handlingset -e has exceptionsLong-lived stateruns for hoursWindows usersneeds WSL or Git BashOver 100 linesor tangled control flowSpeed mattersa process per command

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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:

Whole-number arithmetic, floating point, jq and set -e, tried Python · warning_signs_session.py
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

  1. Question 1 of 3 What does echo $(( 0.1 + 0.2 )) do in Bash 5.3.20?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: It fails with an arithmetic syntax error, because Bash arithmetic has whole numbers only

    $(( 7 / 2 )) printed 3 and $(( 0.1 + 0.2 )) failed, so sums with decimals need awk or a program.

  2. Question 2 of 3 With set -e on, check() { false; echo "check went on"; } runs as if check; then echo passed; fi. What happens?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: check goes on after false, prints its line, and passed is printed

    The session printed "check went on after false" and "check passed". A script whose safety rests on set -e has to keep every such exception in mind.

  3. Question 3 of 3 A 60-line script counts the 404 answers per path in a web log and prints the five largest counts. Which signs of an outgrown script does it show?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: None: it runs tools on flat text and counts whole numbers

    Go through the seven checks: no nested data, no decimals, few error cases, no lasting state, no Windows users, short, and no request waits on it.

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:

Interactive · answer and compare Which tool fits this task? Answer the questions about your task; tools that do not fit are ruled out, each with the reason.
What does the task mostly do?
Must it run where Bash may be missing, such as a minimal container image?
Will it pass about 100 lines, keep state for long, or need careful error handling?
Must it run on Windows laptops as well?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Bash, POSIX sh, awk and Python against five things a task may need
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

  1. Question 1 of 2 A container image has /bin/sh and may not have Bash. Its entrypoint sets two variables and starts the server. Which tool fits?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: POSIX sh

    Answer "Runs programs, moves files" and "Yes, Bash may be missing" in the table: awk and Bash are ruled out, and Python fits only with a caveat.

  2. Question 2 of 2 A tool must run on Windows laptops and in Linux CI, read nested JSON from an API and keep a state file between runs. Which tool fits, and why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Python: nested data, lasting state and Windows users are three signs at once

    With "Nested data or exact decimals" and "Yes, on Windows too", the table rules out Bash, POSIX sh and awk.

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:

weak_count.py: one string for the shell, and check=True Python · weak_count.py
"""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}")
count_errors.py: an argument list, and grep's own statuses Python · count_errors.py
"""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:

One string through the shell against an argument list Python · subprocess_session.py
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:

Interactive · message by message A list or one string for the shell Step through one call of each kind and watch who reads the file name.
A list or one string for the shell, step 5 of 5. Messages so far: Python to grep: five arguments; grep to Python: 1, status 0; Python to /bin/sh: one string; /bin/sh to grep: grep … notes; /bin/sh to Python: injected.log, 0.Python/bin/shgrepfive arguments1, status 0one stringgrep … notesinjected.log, 0

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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".
  3. 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.
  4. 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.
  5. 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

  1. Question 1 of 2 Which call passes a file name to grep without any shell reading it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: subprocess.run(["grep", "-c", "--", "ERROR", name])

    Only the first call keeps the name away from a shell. In the session the name "notes; echo injected.log" stayed one argument, and grep counted 1.

  2. Question 2 of 2 clean.log has no ERROR line. What status did grep return for it, and what did count_errors.py print?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Status 1 and a count of 0, so it printed clean.log: 0

    grep has three outcomes: 0 when a line was selected, 1 when none was, more than 1 for an error. Reading the status yourself keeps a correct 0 from looking like a failure.

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:

The same lines in Bash, zsh and PowerShell Python · shells_session.py
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

  1. Question 1 of 2 files="a.txt b.txt"; for f in $files; do echo "[$f]"; done prints two lines in Bash. What does zsh -f print for the same line?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: One line: [a.txt b.txt]

    The same line gave another answer and no error. That is why scripts for others name Bash on their first line instead of running in whatever shell a user has.

  2. Question 2 of 2 Why did PowerShell sort 3, 10, 2 as 2, 3, 10 but '3', '10', '2' as 10, 2, 3?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Its pipeline passes objects: numbers sort as numbers and strings as text

    Bash pipes carry text, so sort compared 3, 10 and 2 as text and printed 10, 2, 3.

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:

summary: a 7-line wrapper that hands over to log_summary.py Shell · summary.sh
#!/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:

The wrapper from another folder, with good and bad arguments Python · wrapper_session.py
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:

Interactive · message by message A wrapper that hands over with exec Step through one call of the wrapper, from the terminal to Python and back.
A wrapper that hands over with exec, step 4 of 4. Messages so far: Terminal to summary: one argument; summary to python3: same process; python3 to Terminal: status 2.Terminalsummaryreplacedpython3one argumentsame processstatus 2

The drawing shows the whole scene; its 4 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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

  1. Question 1 of 2 The wrapper ends with exec python3 "$here/log_summary.py" "$@". Called with no file, what status does the terminal see?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 2, because exec replaced Bash with Python, and Python exited with 2

    The session printed the usage line and [exit status 2], and for a missing file, Python's message and status 1.

  2. Question 2 of 2 Why does the wrapper pass "$@" to the program?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It passes each argument as one word, so ../my logs/access.log stays one name

    A quoted "$@" keeps the arguments exactly as they arrived, which is what a wrapper owes the program behind it.

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.

  1. Question 1 of 18 What kind of work is a shell script at its best at?

    Choose one answer.

    Show the answer: Question 1 of 18

    Answer: Running programs in order and checking how each one ended

    Google's guide accepts shell for small utilities and wrappers that mostly call other programs.

  2. Question 2 of 18 Which tool fits: run the tests, build a container image and push it in one CI step, with the build options kept in an array?

    Choose one answer.

    Show the answer: Question 2 of 18

    Answer: Bash

    This is glue that uses one Bash feature, so Bash fits better than POSIX sh.

  3. Question 3 of 18 Which fits: print every line that contains TODO in a repository, with its file name and line number?

    Choose one answer.

    Show the answer: Question 3 of 18

    Answer: One grep -rn TODO . command in Bash

    When one existing program already does the job, the shell only has to start it.

  4. Question 4 of 18 In the recorded runs, what did log_summary.py do with the line whose latency was -?

    Choose one answer.

    Show the answer: Question 4 of 18

    Answer: It stopped, printed the line with its number 25, and ended with status 1

    The Python version names the bad line; the Bash version printed a lower mean with status 0.

  5. Question 5 of 18 You want log-summary.sh to stop on a latency that is not a number, as the Python version does. What is the smallest change?

    Choose one answer.

    Show the answer: Question 5 of 18

    Answer: An awk rule that tests $5 against ^[0-9]+$ and exits with status 1, as the last command of the session did

    One sign can often be fixed in place with one line of awk; many such checks are a sign to move.

  6. Question 6 of 18 Which of these are signs that a script should become a program? Select every one.

    Choose every answer that is right.

    Show the answer: Question 6 of 18

    Answer:

    • It has passed about 100 lines
    • Sums with exact decimals
    • Nested JSON that needs several jq lines

    The seven signs are nested data, decimals, many error cases, lasting state, Windows users, more than about 100 lines, and speed.

  7. Question 7 of 18 Which tool fits: merge two JSON exports by user id and list the users whose plan changed?

    Choose one answer.

    Show the answer: Question 7 of 18

    Answer: Python

    Nested data beyond one jq line is the first of the seven signs.

  8. Question 8 of 18 Which tool fits: a small tool that colleagues run on Windows laptops with no Bash installed (no WSL, no Git for Windows), to rename and resize photos?

    Choose one answer.

    Show the answer: Question 8 of 18

    Answer: Python

    Windows users are one of the seven signs: a tool for them is easier to hand over in Python.

  9. Question 9 of 18 A nightly job remembers which files it uploaded, retries failed uploads across runs with growing pauses, and emails a summary. It is already 180 lines. Which signs does it show? Select every one.

    Choose every answer that is right.

    Show the answer: Question 9 of 18

    Answer:

    • Error handling with many cases
    • More than about 100 lines
    • Lasting state

    Three signs at once: move the logic into a program, and keep Bash only for the glue a scheduler calls.

  10. Question 10 of 18 Which tool fits best: the mean response time per path of a 2 GB space-separated log, in one pass?

    Choose one answer.

    Show the answer: Question 10 of 18

    Answer: awk

    Flat columns with decimals in one pass are the work awk was made for.

  11. Question 11 of 18 In the table, you answer that the task mostly runs programs and moves files. Which tool does that answer rule out?

    Choose one answer.

    Show the answer: Question 11 of 18

    Answer: awk

    Each rule of the table rules out what cannot do the job, with the reason shown on its row.

  12. Question 12 of 18 With shell=True on Linux or macOS, which shell does subprocess.run start?

    Choose one answer.

    Show the answer: Question 12 of 18

    Answer: /bin/sh

    Because /bin/sh reads the string, everything in it is shell code, including the parts that came from users.

  13. Question 13 of 18 A Python tool must run tar to pack a folder into an archive, with both names typed by users. Which call is right?

    Choose one answer.

    Show the answer: Question 13 of 18

    Answer: subprocess.run(["tar", "-czf", out, "-C", folder, "."], check=True)

    Pass one string per argument. check=True suits a tool whose only success is 0, unlike grep, whose 1 means no match.

  14. Question 14 of 18 Your login shell is zsh. A script starts with #!/usr/bin/env bash, and you run it as ./tool.sh. Which shell reads it?

    Choose one answer.

    Show the answer: Question 14 of 18

    Answer: Bash, because the first line names it

    Scripts for others name Bash on their first line, so the reader's login shell never matters.

  15. Question 15 of 18 Since which macOS release do new user accounts get zsh as their default shell?

    Choose one answer.

    Show the answer: Question 15 of 18

    Answer: macOS 10.15

    The terminal on a newer Mac starts zsh, so a Bash script there must be started by its first line or by bash.

  16. Question 16 of 18 A teammate pastes a Bash line with [[ $x == y ]] and ${v:-none} into fish. What should they expect?

    Choose one answer.

    Show the answer: Question 16 of 18

    Answer: It fails, because fish has neither [[ nor the colon-dash default

    fish is a fine interactive shell, but a Bash script needs Bash to run it.

  17. Question 17 of 18 What does exec python3 prog.py "$@" do to the Bash process that runs it?

    Choose one answer.

    Show the answer: Question 17 of 18

    Answer: It replaces it: Python runs in the same process, with the same ID

    Because nothing of Bash is left, the program's output and exit status reach the caller unchanged.

  18. Question 18 of 18 A team moves the logic of a Bash tool into Python, but people and scheduled jobs call it as report --since 7. What keeps every caller working?

    Choose one answer.

    Show the answer: Question 18 of 18

    Answer: A thin wrapper named report that checks for python3 and ends with exec and "$@"

    Keep the command and move the logic: the wrapper is a few lines, and the program can grow with tests.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.sh ran 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.run a list and read each tool’s statuses yourself; one string with shell=True let 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 exec keeps 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.