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.