On this page

The Automation chapter showed how to write your first script: a file of commands, a shebang, a chmod +x, done. That first script works right up until the day a filename has a space in it, an argument is missing, or a command in the middle fails and nobody notices. This guide is about the tools bash gives you for that day. Same shell, deeper end.

You need nothing installed. Bash is the default shell on Ubuntu, Fedora and Arch, and everything here was checked on bash 5.3 (5.3.9 on Ubuntu and Fedora, 5.3.15 on Arch) with identical behavior throughout.

Variables and quoting

Assignments have no spaces around the =. Write name="my file.txt", never name = "my file.txt" — with spaces, bash reads name as a command and answers name: command not found.

Using a variable is $name. Quoting it is the whole game:

#!/bin/bash
name="my file.txt"
ls $name
ls: cannot access 'my': No such file or directory
ls: cannot access 'file.txt': No such file or directory

Unquoted, the variable’s value gets split on spaces before ls ever runs: bash saw two files where you saw one. Quote it and the filename survives intact:

ls "$name"

The habit worth building: put quotes around every variable, always, even when you are sure it has no spaces.

Arithmetic is the one place quoting steps aside. Double parentheses run the math: $((2 + 3 * 4)) gives 14 (multiplication binds before addition), and the result comes out as a single word, so count=$((count + 1)) needs no quotes to be safe.

When a variable sits glued to other text, brace it: "${file}.txt" — otherwise bash reads $file.txt as a variable named file.txt. And ${name:-backup} yields backup whenever name is unset or empty, which makes it the polite way to handle optional arguments: dir="${1:-.}".

Exit codes

Every command finishes with an exit code, and $? holds the code of the last one. Zero means success. Anything else means something went wrong, and the number says what. A failed ls /nonexistent exits 2 on GNU ls. You do not need to memorize numbers — check zero versus non-zero and you are fine.

Your scripts get to pick their own code with exit. exit 0 declares success. exit 1 is the conventional complaint. When a script ends without an exit, its code is the code of the last command it ran.

&& and || build on this. cmd && next runs next only if cmd succeeded; cmd || fallback runs fallback only if it failed. Chains stop at the first failure, which makes one-liners like mkdir -p backups && cp -r files backups/ read exactly the way they behave.

Tests: [ ] and [[ ]]

The if statement runs a command and reacts to its exit code. Most of the time that command is a test:

if [ -f ~/.bashrc ]; then
  echo "bashrc exists"
fi

The familiar operators: -f file exists, -d directory exists, -z empty string, -n non-empty string, -eq and -lt for number comparisons, and ! in front reverses the whole test: [ ! -d "$dir" ] is true when the directory is missing. The single most quoted error in shell scripting:

#!/bin/bash
x="a b"
if [ $x = y ]; then
  echo "equal"
fi
check.sh: line 3: [: too many arguments

Unquoted, $x split into two words and [ counted three arguments where a comparison needs two. (The path in bash’s error messages is the script’s filename and the number is the offending line, so check.sh: line 3: points at the if. Save the snippet as check.sh and you will see exactly this.) An empty variable fails differently:

check.sh: line 3: [: =: unary operator expected

Bash has a fix. [[ ]] is its upgraded test, and it does not word-split variables at all — [[ $x = y ]] works with spaces, with empty values, and without a single quote in sight. Inside [[ ]] you can also write && and || directly. Inside [ ] those are syntax errors and the script dies with [: missing `]'.

Pattern matching is another [[ ]] privilege. On the right-hand side of ==, an unquoted value is a glob: [[ $s == hel* ]] is true for hello. Quote the pattern and it compares literally instead.

One trap worth knowing even if you never step on it: inside [ ], the characters < and > are redirects, not comparisons. [ "zzz" > out ] returns true and, as a side effect, creates a file named out. Inside [[ ]] they compare strings. The short version: in bash scripts, use [[ ]].

Loops

The for loop walks a list:

for f in *.txt; do
  echo "found: $f"
done

The *.txt is an ordinary glob, so the loop gets one turn per matching file — and the body quotes "$f", because an unquoted filename with a space in it splits into words. Counting works the same way: for i in 1 2 3; do echo "$i"; done.

For going through a file line by line, the redirect form is the one to learn. Chapter 13 showed > sending output into a file; < points the same arrow the other way and feeds a file into a command. And read line pulls the next line of that file into a variable called line:

while read line; do
  echo "got: $line"
done < notes.txt

Two quirks, both verified the hard way. A last line without a trailing newline never reaches the body — printf 'a\nb' feeds the loop one line, and b quietly vanishes. Bigger surprise: the pipe form of this loop loses your variables.

counter=0
cat notes.txt | while read line; do
  counter=$((counter+1))
done
echo "counter after pipe loop: $counter"
counter after pipe loop: 0

Three lines went in, the counter read 0 afterward. The loop on the right of a pipe runs in a subshell — a child copy of the script — and its variables die with it. The done < file form avoids the whole problem, which is why this guide uses it.

case

When one script does several jobs depending on a word, case is the honest tool:

#!/bin/bash
case "$1" in
  start)
    echo "starting"
    ;;
  stop)
    echo "stopping"
    ;;
  *)
    echo "Usage: $0 start|stop"
    ;;
esac

Patterns are globs, so h*) matches hello and help. The first matching branch runs and case exits — there is no falling through to later branches. The *) branch is the catch-all, and giving it a usage message is how scripts teach you their own interface. ($0 is the script’s own path: in a usage line it prints whatever you typed to run the script.)

The ;; between branches is mandatory. Leave one out in the middle and bash refuses the whole script with syntax error near unexpected token ')'. The final ;; before esac is optional; writing it anyway keeps copy-pasted branches from biting.

Functions

A function is a named block of commands:

greet() {
  echo "Hello, $1"
}

greet "Ada"

Inside a function, $1 is the function’s own first argument — the script’s $1 is untouched by the call. return 2 sets the function’s exit code (check it with $? after the call). exit would end the entire script, which is rarely what a helper function should do.

Variables created inside a function are global by default, and that leaks. The local keyword keeps them inside:

total=0

add() {
  local amount="$1"
  total=$((total + amount))
}

Using local for everything a function creates is cheap insurance. Without it, two functions that both use a variable named count will one day overwrite each other in ways that take an hour to find.

$@ — every argument at once

One argument was enough for the scripts so far, but scripts that process files usually want all of them. Bash has a shorthand for the complete list: "$@". Inside the quotes, it expands to every argument the script received, each one kept whole — so the quoting habit applies even here.

#!/bin/bash
for f in "$@"; do
  echo "processing: $f"
done

Run it with two files, one of them containing a space, and the loop runs twice with the filename intact both times:

$ bash process.sh "my file.txt" second.txt
processing: my file.txt
processing: second.txt

Without the quotes, the file with the space would be split into two broken names — the entire reason Section 1 was so insistent. If you need the count instead of the values, $# holds it.

When scripts go wrong

Two settings turn silent disasters into loud ones, and together they are the first two lines of most well-behaved scripts:

#!/bin/bash
set -eu

set -e stops the script at the first failed command, instead of stumbling on as if nothing happened. With it set, a failed ls ends the script right there; without it, the error prints, the script carries on, and whatever depended on the missing file produces garbage two lines later.

It has exceptions, and they are sensible once you see them. A command inside an if condition may fail — that is what the condition is for. So is a non-final command in an && chain: false && true does not kill the script, though a failing final command in the chain does. A while read loop reaching the end of its file is not a failure either. The loop just ends.

set -u makes unset variables an error instead of empty strings. With it, a typo like $naem stops the script with the error naem: unbound variable. Without set -u, that typo would silently expand to nothing and the script would run on without its argument. There is more where that came from — set -o pipefail among it — but those two carry a small script a long way.

trap: cleaning up

Scripts that create things owe the world a cleanup, even when they die on the way. trap schedules a command to run when the script exits:

#!/bin/bash
set -eu

tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT

echo "working in $tmp"

mktemp -d creates a fresh, uniquely named directory and prints its path. Wrapping a command in $( ) runs it and captures that output (the same trick chapter 15 used for the date stamp), so tmp ends up holding the directory name.

The EXIT trap fires when the script ends, however it ends — reached the last line, hit an exit, or got killed by set -e after a failed command. Cleanup written once, at the top, covers every path.

Interruptions have their own trap. trap 'echo interrupted' INT runs its command when the script receives SIGINT, the interrupt signal. One quirk: a script that already ignores SIGINT when it starts keeps ignoring it. Traps belong in your own scripts, not in scripts that other programs start and signal.

The backup script, grown up

Chapter 15’s version:

#!/bin/bash
# backup.sh — copy a directory to ~/backups with a date stamp
dir="$1"
stamp="$(date +%Y%m%d)"
cp -r "$dir" "$HOME/backups/$dir-$stamp"
echo "Backed up $dir to backups/$dir-$stamp"

It works, and it fails in all the quiet ways: run with no argument, it copies nothing and says nothing useful; pointed at a directory that does not exist, cp complains and the script reports success anyway. Here it is again:

#!/bin/bash
# backup.sh — copy a directory to ~/backups with a date stamp
set -eu

dir="${1:-}"
if [ -z "$dir" ]; then
  echo "Usage: $0 directory"
  exit 1
fi

if [ ! -d "$dir" ]; then
  echo "No such directory: $dir"
  exit 1
fi

stamp="$(date +%Y%m%d)"
mkdir -p "$HOME/backups"
cp -r "$dir" "$HOME/backups/$dir-$stamp"
echo "Backed up $dir to backups/$dir-$stamp"

Same job. The differences are all in how it fails: no argument now gets a usage line, a wrong name gets a clear complaint, a failed copy stops the script before the final lie of a success message, and every expansion is quoted. That is what “holds up” means in practice — not more features, just fewer silent failures.

Where you are now

Your scripts can take arguments without guessing, fail loudly, clean up after themselves, and survive filenames with spaces in them. When a script outgrows one screen, run it through ShellCheck — paste it in and the classic mistakes from this guide get flagged by name. The GNU Bash manual documents everything this guide skipped. Chapter 15’s Automation remains the place for the cron side of automation, and the cheat sheet holds the one-liners.