Bash Scripting¶
A script is just the commands you'd type by hand, saved to a file so they run the same way every time. What turns "some commands in a file" into a script you can trust in a real workflow is a small, fixed set of building blocks — variables, conditionals, loops, functions — plus the discipline to make failure loud instead of silent.
TL;DR — in 30 seconds:
- Every script starts with a shebang (
#!/usr/bin/env bash) telling the OS which interpreter to run it with, and needs execute permission (chmod +x) to run directly. set -euo pipefailat the top of a script is close to a default: it stops on the first error, on any undefined variable, and on a failure hiding inside a pipeline — instead of silently continuing.- A script's exit code (
0= success, non-zero = failure) is how it reports success or failure to whatever called it —exit 1(or any non-zero) at the point of failure, not just falling off the end.
1. Shebang, variables, and arguments¶
#!/usr/bin/env bash
set -euo pipefail
name="world"
echo "Hello, $name"
- The shebang (
#!/usr/bin/env bash) must be the very first line — it tells the OS which interpreter runs the file.#!/usr/bin/env bash(rather than a hardcoded#!/bin/bash) findsbashvia$PATH, which is more portable across systems wherebashisn't always at the same absolute path. - Variables are assigned with no spaces around
=(name="world", notname = "world") and read with a$prefix ($name, or${name}when you need to disambiguate it from surrounding text). - Always quote variable expansions (
"$name", not$name) — an unquoted variable is subject to word splitting and glob expansion, which breaks the moment its value contains a space. - Positional arguments —
$1,$2, … are the script's arguments in order;$@is all of them (as separate words — the standard choice);$#is the argument count;$0is the script's own name.
2. Conditionals¶
if [[ -f "$file" ]]; then
echo "exists"
elif [[ -d "$file" ]]; then
echo "it's a directory, not a file"
else
echo "missing"
fi
- Prefer
[[ ... ]](a bash keyword) over the older[ ... ](an actual command,test) —[[handles quoting more forgivingly and supports&&/||inside the brackets directly. - Common file tests:
-f(regular file exists),-d(directory exists),-e(anything exists),-x(executable). Common string/number tests:-z(string is empty),-n(string is non-empty),-eq/-ne/-gt/-lt(numeric comparison — not==/</>, which compare strings). &&runs the next command only if the previous one succeeded (exit code 0);||runs it only if the previous one failed — both read the previous command's exit code, the same signalifreads.
3. Loops¶
for f in *.log; do
echo "processing $f"
done
while read -r line; do
echo "line: $line"
done < input.txt
for ... in ...iterates over a list — a glob (*.log), a set of words, or the output of a command ($(command)).while <condition>repeats as long as the condition succeeds —while read -r line; do ... done < fileis the standard idiom for processing a file line by line (-rpreventsreadfrom mangling backslashes in the input).breakexits a loop immediately;continueskips to the next iteration.
4. Functions and exit codes¶
greet() {
local name="$1"
echo "Hello, $name"
return 0
}
greet "world"
- A function groups commands under a name you call like any other command;
localscopes a variable to the function instead of leaking it into the whole script — always preferlocalinside a function. - A function's (and a script's) exit code is a number:
0means success, any value1–255means failure.return <n>sets a function's exit code;exit <n>ends the whole script with that code. Whatever called your script (another script, a CI pipeline, a human checking$?) uses that number to decide what happened — a script that always exits0regardless of what actually happened is lying to its caller.
5. set -euo pipefail — making failure loud¶
By default, bash is forgiving in ways that are dangerous in a script: it keeps running after a command
fails, silently treats a typo'd variable as an empty string, and only reports the last command's exit
code in a pipeline even if an earlier one failed. set -euo pipefail turns off all three:
flowchart TB
S["set -euo pipefail"] --> E["-e: stop the script<br/>the instant any command fails"]
S --> U["-u: error immediately on<br/>any undefined variable"]
S --> P["pipefail: a pipeline's exit code<br/>is its first failing command, not just its last"]
| Flag | Without it | With it |
|---|---|---|
-e |
A failing command's error is silently ignored; the script keeps going with bad state. | The script stops immediately at the first command that fails. |
-u |
A typo'd variable name ($nmae) silently expands to an empty string. |
Referencing an undefined variable is itself an error, and stops the script. |
pipefail |
cmd1 \| cmd2 only reports cmd2's exit code — cmd1 can fail completely unnoticed. |
The pipeline's exit code is the first non-zero exit code among all its commands. |
Put together, set -euo pipefail is close to a default for any real script — it converts "the script
technically finished" into "the script tells you the moment something goes wrong," which is exactly the
difference between a script you can trust unattended and one you have to babysit.
Common gotchas
- Forgetting
chmod +xand wondering why./script.shsays "permission denied." Fix: a script needs the execute bit set to be run directly by path —chmod +x script.shonce, or run it asbash script.shinstead, which doesn't require the bit. - Leaving a variable expansion unquoted. Fix: always write
"$var", not$var— an unquoted expansion undergoes word splitting, so a value containing a space silently becomes multiple arguments. - Using
==/</>for numeric comparison inside[[ ]]. Fix: those compare strings ("10" < "9"is true as strings!) — use-eq/-ne/-gt/-lt/-ge/-lefor numbers. - Writing a script with no
set -euo pipefailand no exit codes, then trusting its "success." Fix: without it, a script can fail halfway through and still report overall success to whatever called it — add it at the top of every real script, and use explicitexit <n>at points of failure.
Check yourself — a script has no set -e. Its third command fails, but the script has ten more commands after it. What happens?
All ten remaining commands still run — without -e, bash does not stop on a failing command by
default; it just moves on to the next line, potentially operating on bad or partial state left behind
by the failure.
Check yourself — why does pipefail matter even though a pipeline already has an exit code without it?
Without pipefail, a pipeline's exit code is only the last command's — if an earlier command in
the chain fails but a later one still exits 0 (e.g. false | grep -q anything), the whole pipeline
reports success even though something upstream actually failed. pipefail makes the pipeline's exit
code the first non-zero one in the chain, so an upstream failure can't hide behind a downstream success.
You can defend this when you can write a script with a variable, a conditional, a loop, and a function
from scratch, explain what each of -e, -u, and pipefail individually protects against, and state what
exit code your own script returns in both its success and failure paths.