OnCallReady

Lesson 6.1 · Bash Scripting · 13 min read

The header every script gets

In plain words

Think of the first page of a recipe book that says "use a gas oven, stop cooking if anything burns, never use an ingredient that is not on the list, and only separate ingredients by line, not by spaces". Every recipe after that follows those house rules without repeating them.

The script header is that page. #!/usr/bin/env bash says which cook reads the recipe (bash, not dash). set -e means stop at the first failure. set -u means a variable nobody defined is an error, not an empty word. set -o pipefail means a failure anywhere in a pipe counts. IFS=$'\n\t' means split only on newlines and tabs. And the exit code at the end is the thumbs up or down every caller reads.

Why write scripts at all

Every morning you type the same six commands to check the box. One day you want them to run at 3am from a systemd timer (Ch 2), with nobody watching. So you put them in a file and let bash run the file. That file is a script.

The problem this chapter solves: nobody is watching a script. If one command in it fails, bash by default just runs the next one - with a missing file, an empty value, the wrong directory. A good script stops at the first problem and says so.

What you need to know already: exit status and $?, && and ||, stdout vs stderr, > and pipes | (1.7 "Driving the shell"); chmod +x and the x bit (4.5); signals and exit 137 (3.6, 5.11).

The words you need first

greeting="hello"
echo "$greeting world"      # prints: hello world
echo "[$typo]"              # prints: []   - an unset variable is empty

Four lines, then the script

Every script in this chapter starts like this:

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

One line at a time.

#!/usr/bin/env bash - the shebang (from "hash" # + "bang" !). It must be the very first line. When you run ./script.sh, the kernel reads it to learn which interpreter to start. /usr/bin/env bash means "find bash on the PATH" (the list of folders the shell searches for commands) instead of hard-coding /bin/bash.

Why bash and not sh? On Ubuntu /bin/sh is dash, a smaller shell that lacks many things this chapter uses (arrays, [[ ]], $'...'). A script that says #!/bin/sh but uses bash features works on one machine and breaks on the next.

set changes how bash itself behaves. Each letter is one option:

rm -rf "$BUILD_DIR/"    # the variable was set as BULID_DIR (typo).
                        # Without -u this expands to rm -rf / - everything.

(false and true are tiny commands that do nothing except exit 1 and exit 0. They are handy for experiments.)

IFS=$'\n\t' - IFS is the internal field separator: the characters bash uses to cut an unquoted value into separate words. Normally space, tab and newline. Removing the space means a filename like old report.txt is not cut in two by accident. $'\n\t' is bash's way to write "newline, tab" (a string in $'...' understands backslash codes). It is a safety net - proper quoting (6.6) matters more.

Exit codes, as a script sees them

You met these in 1.7:

0        success
1-125    the program's own errors
126      found but not executable
127      command not found
128+N    killed by signal N  ->  130 = Ctrl+C (2), 137 = SIGKILL (9),
                                143 = SIGTERM (15)

A script ends with an exit code too: the one you give to exit N, or else the status of the last command it ran. $? holds the most recent status - read it immediately, because the next command overwrites it:

cp a.txt b.txt
status=$?          # save it first
echo "cp said $status"

Why it matters: whatever runs your script only sees that number. A script that always exits 0 is invisible - a systemd service will not restart it, a timer will not log a failure, a person running it will think it worked. Exit non-zero when something is wrong.

What you can now do

Why it helps

Almost every script you write for work runs unattended: from cron or a systemd timer, as the start command of a service, or called by another script. There is nobody watching the output, so the script must stop on failure and return a non-zero exit code, or whatever runs it reports success on a broken deploy. The header is the cheapest insurance: -u alone prevents the classic rm -rf "$TYPO/" wiping a filesystem.

The shebang matters too: #!/bin/sh with bash syntax works on your laptop and fails on Ubuntu and Debian servers, where /bin/sh is dash. In reviews, a missing header or exit 0 at the end of every path is one of the first comments you will make on a teammate's script.

Commands in this lesson

bash

FAQ

Why #!/usr/bin/env bash and not #!/bin/bash?

/usr/bin/env bash finds the first bash on PATH, which works on systems where bash is not in /bin, such as macOS with a Homebrew bash or NixOS. On Ubuntu both work. The trade-off is that PATH decides which bash runs, which some security-sensitive scripts avoid by hardcoding /bin/bash. Either is fine; #!/bin/sh is not, if you use bash features.

Does IFS=$'\n\t' replace quoting?

No. It limits word splitting of unquoted expansions to newlines and tabs, so a value with a space survives by accident. Unquoted expansions still undergo globbing, and values containing newlines or tabs still split. It also changes how "$*" joins and how read splits unless you set IFS per command. Quoting every expansion is the actual fix; IFS is a safety net some people skip.

Can I put set -euo pipefail on the shebang line?

Partly. Linux passes everything after the interpreter as a single argument, so #!/bin/bash -e works, but #!/usr/bin/env bash -euo pipefail fails because env receives "bash -euo pipefail" as one name, unless you use env -S. Options on the shebang are also ignored when someone runs bash script.sh. Put set -euo pipefail on its own line after the shebang.

What exit code should my script return?

0 for success, non-zero for any failure. Conventionally 1 for general errors and 2 for usage errors, with other small numbers documented for specific failure types. Avoid 126 and above, which the shell uses for "not executable", "not found" and death by signal. If the last command in a script fails and you do not exit explicitly, the script returns that command's status.

Does set -u break scripts that use optional variables?

Only if they reference unset variables without a default. Use ${VAR:-} for "empty if unset" or ${VAR:-default} for a fallback, and ${1:-} when checking optional arguments. Arrays need care in older bash, where "${arr[@]}" on an empty array triggered an error before bash 4.4. The small effort pays for itself the first time a typo is caught.

In an interview Junior

What does set -euo pipefail do, and why is it recommended?

Nobody watches a script, and by default bash just runs the next line after a failure - with a missing file, an empty value, the wrong directory. These three options make it stop at the first problem instead:

The full header is #!/usr/bin/env bash (the shebang: find bash on the PATH - not sh, which is dash on Ubuntu), then set -euo pipefail, then IFS=$'\n\t' so values are not split on spaces. It is a safety net, not a guarantee: -e has blind spots.

Also asked: Why use #!/usr/bin/env bash instead of #!/bin/sh on Ubuntu? · What exit code should a script return when something goes wrong, and why does it matter? · What is IFS, and why do scripts change it?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.