Shell scripts that fail safely
Turn commands into scripts with variables, set -euo pipefail and clear exit codes, and know when to switch to Python.
Create a free account Try part of it here
Why it matters
Every team has small shell scripts: one to deploy, one to back up a database, one that a CI job runs before tests. They're short, they run unattended, and by default they keep going after a command fails, which is how a backup script reports success after copying nothing, or a deploy script ships a build that never compiled. This lesson shows how a script runs, how to give it variables, and the one line that makes it stop safely when something goes wrong.
What you'll be able to do
- Turn a sequence of commands into a script you can run again
- Use variables in scripts, and quote them so values with spaces survive
- Make scripts stop on errors, unset variables and failed pipelines with set -euo pipefail
- Read exit codes, and know when a shell script should become a Python program
What it covers
- A script is commands in a file
- Variables, and why to quote them
- Giving a script arguments
- set -euo pipefail
- A gotcha: grep and set -e
- Exit codes
- When a script should become Python
Show the answer
Scripts keep going after a failed command unless told to stop
By default the shell runs every line in turn, whatever happened before. A single line, set -e, makes it stop at the first failure.
A script is commands in a file
A shell script is a text file of commands, run top to bottom as if you'd typed them. By convention it ends in .sh and starts with a shebang line, #!/usr/bin/env bash, which tells the system which program should run it. Lines starting with # are comments.
#!/usr/bin/env bash set -euo pipefail LOG=logs/access.log echo "Server errors:" grep -c " 500 " "$LOG"
Run it with bash scripts/report.sh, or make it executable once with chmod +x and run ./scripts/report.sh. Scripts are worth writing as soon as you've typed the same few commands twice: they don't forget a step, they can go in Git next to the code, and a teammate can run them without asking you how.
Show the answer
It names the program that runs the script
The shebang tells the system to run the file with bash when you execute it directly, as ./script.sh.
Variables, and why to quote them
NAME=value sets a variable, with no spaces around the =; NAME = value would try to run a command called NAME. $NAME or ${NAME} uses it. Environment variables such as $HOME work the same way, which is how scripts receive settings and secrets without having them written inside.
Quote variables when you use them: "$LOG". If the value ever contains a space, an unquoted $LOG splits into two words, and grep would look for its pattern in two files that don't exist. Double quotes keep the value together and still expand it; single quotes don't expand at all, so '$LOG' is the four characters $LOG. That difference matters when you write a script line with echo: single quotes write $LOG into the file for later, double quotes write its value now.
echo 'Reading $LOG' && echo "Reading $LOG"
Show the answer
Reading $LOG, then Reading logs/access.log
Single quotes keep text exactly as written; double quotes expand variables. That's why the first line still shows $LOG.
Giving a script arguments
Scripts take arguments the way commands do. Inside the script, $1 is the first argument, $2 the second and $# how many there are, so bash scripts/report.sh logs/old.log runs with $1 set to logs/old.log. Under set -u a missing argument is an error, so give a default when there's a sensible one: LOG="${1:-logs/access.log}" uses the first argument if there is one and the default if not.
When there's no sensible default, check and explain how to call the script:
if [ $# -lt 1 ]; then echo "usage: deploy.sh ENVIRONMENT" >&2 exit 2 fi
>&2 sends the message to standard error, the stream meant for problems, so it doesn't get mixed into the real output when someone pipes the script into something else. A usage line like this is the cheapest documentation a script can have, and it's the first thing a teammate sees when they run it wrong.
Show the answer
logs/old.log
${1:-default} uses the first argument when one was given; the default only fills in when it wasn't.
set -euo pipefail
Put this on the line after the shebang in every script you write:
-e: stop as soon as a command fails, instead of carrying on;-u: treat an unset variable as an error, so a typo like$LGOstops the script instead of quietly becoming an empty string (imaginerm -rf "$BUILD_DIR/"with BUILD_DIR unset);-o pipefail: a pipeline fails if any stage fails, not only the last one. Without it,curl -sf URL | jq .succeeds even when curl failed, because jq, the last stage, didn't.
The three together turn a script that limps on from a failure into one that stops at the line where it went wrong, which is the line you'll read first when you debug it.
curl -sf https://api.trailhead.test/products | wc -l
Show the answer
0, because wc -l, the last stage, succeeded
Without pipefail a pipeline reports only its last command's exit code, and wc -l happily counts zero lines. With pipefail it would report curl's failure.
Write a sentence or two, then compare it with a good answer in the lesson.
A gotcha: grep and set -e
One gotcha follows from these rules. grep exits with a failure code when it finds nothing, so under set -e a search with no matches stops the script. With pipefail the same goes for a pipeline such as grep ' 500 ' "$LOG" | wc -l, even though wc -l printed 0. When "no matches" is a normal answer, say so explicitly with || true: grep -c ' 500 ' "$LOG" || true prints the count, 0 included, and the line succeeds. || runs the next command only when the one before it failed, the mirror image of &&.
Show the answer
grep exits with a failure code when nothing matches
No matches is exit code 1 for grep, and set -e stops at any non-zero exit code. Adding || true, as in grep -c TIMEOUT app.log || true, keeps the count and lets the script carry on.
Scripts read top to bottom: how to run, how to behave, what to remember, what to do.
Exit codes
Every command finishes with an exit code: 0 means success, anything else means failure, and the number often says which kind. grep uses 1 for "no match" and 2 for real errors; curl -f uses 22 for an HTTP error. && runs the next command only after a 0 and || only after a failure; a script's own exit code is that of its last command, or whatever it passes to exit. That's what CI systems read: a script that exits non-zero turns the job red.
So end a check script with exit 1 when something's wrong, after printing what it was. A script that prints "ERROR" but exits 0 looks fine to every machine that runs it.
Show the answer
A green, passing job
CI reads the exit code, not the text. Printing a failure but exiting 0 reports success; the script must exit non-zero.
When a script should become Python
Shell scripts are the right tool for gluing commands together: run the tests, build the image, copy a file, call a deploy command. They get fragile once they need data structures, arithmetic, error handling beyond "stop", or careful handling of untrusted input, where quoting mistakes become security holes. A rough rule: past about fifty lines, or the first time you need an array of records, write it in Python, which you can also test.
Whichever you choose, keep scripts in the repository next to the code they serve, run them in CI, and give each one a short comment at the top saying what it does and how to run it. A script nobody can read is a script nobody dares to change.
Show the answer
Matching orders to refunds in two CSVs and totaling by customer
Joining two files and summing by customer needs data structures and arithmetic; Python handles that clearly and testably.
Write a sentence or two, then compare it with a good answer in the lesson.
Practice what you just read
- a practice exercise that checks your code
- a 6-question quiz
- 5 review cards for spaced repetition
Try a quiz question
What does set -e do in a script?