Skip to content

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 pipefail at 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) finds bash via $PATH, which is more portable across systems where bash isn't always at the same absolute path.
  • Variables are assigned with no spaces around = (name="world", not name = "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; $0 is 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 signal if reads.

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 < file is the standard idiom for processing a file line by line (-r prevents read from mangling backslashes in the input).
  • break exits a loop immediately; continue skips 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; local scopes a variable to the function instead of leaking it into the whole script — always prefer local inside a function.
  • A function's (and a script's) exit code is a number: 0 means success, any value 1255 means 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 exits 0 regardless 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 +x and wondering why ./script.sh says "permission denied." Fix: a script needs the execute bit set to be run directly by path — chmod +x script.sh once, or run it as bash script.sh instead, 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/-le for numbers.
  • Writing a script with no set -euo pipefail and 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 explicit exit <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.