Index Ai Academy
Phase 7 · Software engineering · Building software

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.

About 25 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. A script is commands in a file
  2. Variables, and why to quote them
  3. Giving a script arguments
  4. set -euo pipefail
  5. A gotcha: grep and set -e
  6. Exit codes
  7. When a script should become Python
Quick check
Before any explaining, a guess. A deploy script's build step fails, but the script carries on and deploys the old, broken files. What's the usual cause?
  1. The script was missing a comment at the top
  2. Scripts keep going after a failed command unless told to stop
  3. The build step ran too fast
  4. Deploy scripts are meant to ignore errors, on purpose
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.

Quick check
What does the first line of a script, #!/usr/bin/env bash, do?
  1. It sets the script's working folder
  2. It makes the script stop on errors
  3. It names the program that runs the script
  4. It's a comment the shell ignores completely
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.

Quick check
LOG is set to logs/access.log. What does this print?
echo 'Reading $LOG' && echo "Reading $LOG"
  1. Reading $LOG twice
  2. Reading $LOG, then Reading logs/access.log
  3. An error, since $LOG isn't a command name
  4. Reading logs/access.log twice
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.

Quick check
A script starts with LOG="${1:-logs/access.log}". It's run as bash report.sh logs/old.log. What is LOG?
  1. logs/old.log
  2. logs/access.log
  3. logs/old.log logs/access.log
  4. Empty, because set -u stops the script
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 $LGO stops the script instead of quietly becoming an empty string (imagine rm -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.

Quick check
No set -o pipefail this time. The server is down, so curl fails. What exit code does this pipeline report?
curl -sf https://api.trailhead.test/products | wc -l
  1. No code: the shell stops the pipeline
  2. 0, because wc -l, the last stage, succeeded
  3. 22, curl's HTTP error code
  4. 1, because nothing was counted
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.

In your own words
What does set -euo pipefail do, and why quote variables?

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

Quick check
A reminder from the pipes lesson. Under set -e, why can grep -c TIMEOUT app.log stop a script that has nothing wrong with it?
  1. grep exits with a failure code when nothing matches
  2. set -e forbids searching files
  3. grep needs set -u to run
  4. grep -c prints a number, which set -e treats as an error
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.

Try it now

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.

Quick check
A CI job runs a check script that prints "3 tests failed" and then exits with code 0. What does the CI system show?
  1. A red, failing job
  2. A warning that needs a person
  3. Nothing until the next run
  4. A green, passing job
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.

Quick check
Which job has outgrown a shell script?
  1. Printing how many lines mention ERROR
  2. Matching orders to refunds in two CSVs and totaling by customer
  3. Copying last night's backup to a bucket
  4. Running the tests, then building the image if they pass
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.

In your own words
What's the grep and set -e gotcha, and why does a wrong exit code matter in CI?

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

Create a free account

Try a quiz question

What does set -e do in a script?

  1. Stops the script at the first command that fails
  2. Prints each command before running it
  3. Makes unset variables an error
  4. Makes every pipeline fail if any stage fails

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson