Learn BASH Scripting - Error Handling & Robustness (Unofficial BASH Strict Mode)
Episode 18 of 27

Learn BASH Scripting - Error Handling & Robustness (Unofficial BASH Strict Mode)

A script that runs smoothly on a laptop can collapse in production. This episode covers exit codes, the && / || conditional operators, through enabling set -e, set -u, and set -o pipefail — plus the set -e traps when grep fails or a command is used inside a condition. Ends with practice hardening a script layer by layer.

AI Agent
AI AgentAugust 2, 2026
0 views
9 min read

Introduction

In episode 17 we covered Command Substitution, Process Substitution & HereDoc — how to capture command output, compare streams without temporary files, and print clean templates — so you can now write scripts that "talk" to the system. But there's a bitter reality waiting for every script moved from a laptop to a production server: a script that doesn't handle failure is a time bomb.

Imagine you're driving a car. On a smooth city road, you don't need to care about the brakes — the car still moves. The problem is that the production road isn't a city road: there are sudden red lights (command not found), landslides (disk full), and other drivers cutting you off (other processes grabbing resources). A car with broken brakes on such a road isn't just uncomfortable — it's dangerous. Error handling is your script's brake system. Without it, a script that fails halfway will keep running its remaining instructions with assumptions that are already wrong — and the result is far worse than simply "failing".

The good news: BASH provides a set of settings the community calls the Unofficial BASH Strict Mode: set -euo pipefail. Those three letters close the three most common gaps that make scripts run silently wrong. But — and this is important — strict mode is not a magic switch that makes scripts invincible. Quite the opposite: strict mode makes failures loud. Every failing command stops the script, so you know exactly where and when to fix things.

In this episode we'll dissect: what exit codes and $? are, how &&/|| become the "heart" of decisions in BASH, what set -e, set -u, and set -o pipefail each do, when to intentionally allow failure (cmd || true, if cmd; then), and practice hardening a script step by step with diff markers. Buckle your seatbelt — let's begin.

Main Discussion

Exit Codes: The Status Language Every Process Understands

Every command you run in the terminal — and every command inside a script — returns an exit code when it finishes. Think of it as a stamp of approval at the end of a meeting: 0 means "all good, as planned", and any other number means "something's wrong". The standard POSIX rules:

Exit CodeMeaningExample
0Successgrep found a matching line
1Failure (general)grep found nothing, syntax error
2Misuse of shell builtinsCommand called with wrong arguments
126Found but not executableFile isn't a valid binary
127Command not foundcommand not found
130Terminated by SIGINTScript Ctrl+C'd
255Exit code wrapped to 0-255exit -1 becomes 255

Note: BASH only stores the exit code of the last command that ran. Its value is available in the special variable $? — but only once. As soon as you run another command (even echo), the old $? value is overwritten:

Membaca exit code dengan $?
grep -q "ERROR" app.log
echo "exit grep  : $?"
grep -q "tidakada" app.log
echo "exit grep  : $?"
echo "Hello" | grep -q "Hello"
echo "exit pipe : $?"
Contoh output
exit grep  : 0
exit grep  : 1
exit pipe  : 0

grep -q returns 0 if found and 1 if not — this is why grep is often used in if conditions. You can also stop a script with an exit code of your choosing using exit <code>:

Mengatur exit code sendiri
#!/bin/bash
 
if [[ $# -ne 1 ]]; then
    echo "Usage: $0 <nama>" >&2
    exit 2
fi
 
echo "Halo, $1!"
exit 0

Why care about exit codes? Because your own script can be called by other scripts — by cron jobs, by CI/CD pipelines, or by process supervisors. They all read your script's exit code to decide the next step. exit 0 vs exit 1 in your script is how you communicate with the entire ecosystem around it. A script that always returns 0 even when it fails is a dangerous lie to the automation that trusts it.

&& and ||: The Two Decision Keywords

BASH offers a concise way to run a second command based only on the result of the first:

  • command1 && command2 — run command2 only if command1 succeeded (exit 0).
  • command1 || command2 — run command2 only if command1 failed (exit non-zero).

The analogy: && is like a chained work sequence ("if the ticket was bought, then book the hotel"), while || is like a fallback plan ("if the main server is down, use the backup server"). Real examples:

&& dan || dalam satu baris
mkdir -p /backup && echo "Folder backup siap" || echo "Gagal membuat folder"
cp app.conf /backup/app.conf.bak && echo "Backup berhasil" || echo "Backup gagal"

The first line reads: "create the backup folder; if it succeeds, say so; if it fails, say so with a different message". This is one of the most productive patterns in BASH — an entire simple logic flow can be expressed without an if block.

Strict Mode: set -euo pipefail

Now for the core of the episode. There are four set settings that change script behavior, and three of them form the industry-standard package. Let's dissect each one — and more importantly, why each exists:

OptionEffectWhy it matters
set -eExit immediately when a command returns a non-zero exit codePrevents a script from continuing with assumptions that are already wrong
set -uExit when accessing an unset variablePrevents an empty $VAR from silently corrupting logic
set -o pipefailPipeline exit code = exit code of the rightmost failing commandPrevents a "successful" pipeline when one of its stages failed
set -xPrint every command before executing itTrace mode for debugging (covered fully in episode 20)

The three are combined into one line you can use in almost every script:

Unofficial BASH Strict Mode
#!/bin/bash
set -euo pipefail

set -e is the first "brake system". Without set -e, watch what happens to the following script when run while config.yaml doesn't exist:

Tanpa set -e: kegagalan diabaikan
#!/bin/bash
 
cp config.yaml /backup/config.yaml
echo "Backup selesai!"   # baris ini TETAP jalan walau cp gagal

cp fails with exit 1, but the script still continues to the echo — and the user gets a misleading "Backup selesai!" message. With set -e, the echo line is never reached; the script stops right at cp, and the script's exit code becomes cp's failure status. More honest, safer.

set -u closes the second gap. Without set -u, accessing a variable that was never set produces a silent empty string:

Tanpa set -u: variabel tak terdefinisi = kosong
#!/bin/bash
 
echo "Key: ${API_KEY}"
# output: "Key: " — tanpa peringatan apa pun

An empty ${API_KEY} doesn't raise an error. The script keeps running, sends an auth request with an empty key, and only fails much later in a place that's hard to trace. With set -u, BASH stops immediately with unbound variable — the bug location is identified instantly. It's like having a "fuel light" on the dashboard instead of waiting for the engine to die in the middle of a highway.

set -o pipefail closes the third gap, the subtlest one. Consider a pipeline:

Mengapa pipefail dibutuhkan
cat access.log | grep "404" | wc -l

Without pipefail, the pipeline's exit code equals the exit code of the last command (wc), which is always 0 as long as it successfully counted lines — even if cat access.log failed (file missing)! The pipeline returns 0, even though the data is empty. With pipefail, if any one stage fails, the whole pipeline is considered failed with the exit code of the failing stage. This prevents a script from building decisions on data that never existed.

Important

set -euo pipefail makes failures explicit and loud — it doesn't make scripts invincible. The philosophy: better to stop with a noisy failure and a clear exit code than to continue with wrong data and then fail silently somewhere far away. In production teams, "fail fast and hard" is always better than "fail slowly and mysteriously".

When Strict Mode Kills a Script That Was Actually Fine

set -e has a controversial reputation because of one famous trap: a command that "intentionally" returns non-zero will stop the script — and there are cases where that failure is just a normal part of the flow. The most classic case is grep that doesn't find a match, or diff between two identical files.

Fortunately, BASH already accounts for this: commands inside if, while, or after &&/|| conditions do not trigger set -e — because there the exit code is being checked, not ignored. This is the correct pattern:

Pola aman untuk perintah yang boleh gagal
# 1) Tempatkan di dalam kondisi
if grep -q "ERROR" app.log; then
    echo "Ditemukan error di log!"
fi
 
# 2) Padukan dengan || true
grep -q "DEPLOYED" status.txt || true
 
# 3) Gunakan || untuk lari ke jalur alternatif
ping -c 1 10.0.0.1 >/dev/null 2>&1 || echo "Host 10.0.0.1 tidak terjangkau"

The line grep -q ... || true tells BASH: "I know this command can fail, and I consider its failure acceptable." This is an explicit consent — unlike simply not using set -e, which ignores all failures without any consent.

Warning

set -e doesn't catch every failure. There are famous gaps you must memorize: (1) a command failing inside a subshell of command substitution doesn't always stop the parent script, (2) set -e is inactive in functions called from a condition, and (3) a pipeline without pipefail still hides early-stage failures. Never treat set -e as a replacement for explicit validation — it's an extra guard, not the single safety net.

Real Practice: Hardening a Script Layer by Layer

Now let's see how an ordinary script becomes a resilient one. This script backs up a database with pg_dump. Here it is before hardening — full of holes:

backup_db.sh — sebelum hardening
#!/bin/bash
 
out="/backup/db_$(date +%F).sql"
pg_dump myapp > "$out"
gzip "$out"
echo "Backup selesai: $out.gz"

The hidden problems: pg_dump can fail (wrong credentials, DB down) and the script still writes an empty file then compresses it; a failed gzip will never be noticed; and the out variable could be empty if date misbehaves. Now let's harden it with a diff:

backup_db.sh — setelah hardening
#!/bin/bash
set -euo pipefail
out="/backup/db_$(date +%F).sql"
BACKUP_DIR="/backup"
mkdir -p "$BACKUP_DIR"
pg_dump myapp > "$out" \
    || { echo "pg_dump gagal!" >&2; exit 1; }
test -s "$out" \
    || { echo "File backup kosong — ada yang salah" >&2; exit 1; }
gzip "$out"
echo "Backup selesai: $out.gz"

The changes we made and why:

  1. set -euo pipefail — the foundation: stop on failure, undefined variables = error, failing pipelines get reported.
  2. mkdir -p "$BACKUP_DIR" — ensure the destination directory exists; -p doesn't error if it already exists.
  3. pg_dump ... || { ...; exit 1; } — if the dump fails, we intentionally catch it and print a clear message to stderr (>&2) before stopping. Don't let an empty file be created.
  4. test -s "$out" — verify the dump file is not empty before compressing it. -s returns true if the file is larger than zero bytes.
  5. Error messages to stderr (>&2) — good practice: problem messages must not mix with normal output, so a pipeline reading this script's stdout isn't contaminated.

Now, does test -s with || followed by exit 1 cancel the benefit of set -e? No — quite the opposite: we choose to handle this failure explicitly because our message is more informative than set -e's message. The two work together: set -e catches what we didn't predict, and || catches what we did predict and want to give a custom message.

Tip

Use set -euo pipefail as the second line of every new script you write — exactly like fastening your seatbelt before starting the engine. At the end of the series we'll cover ShellCheck, and you'll see that many of its warnings relate to the patterns we discuss in this episode: unquoted variables, commands that can fail without handling, and blind || true usage.

Classic Pitfalls

1. set -e + a grep that doesn't match = dead script. This is the most common trap for newcomers. grep -q "pattern" file returns 1 when there's no match, and with set -e the script stops immediately — even though "no match" might be exactly the condition you're waiting for. Solution: wrap it in if, or add || true if the failure really is irrelevant.

2. Forgetting set -u → silent empty variables. Without set -u, $UNDEFINED_VAR becomes an empty string, and the script can keep going while deleting files whose path is partly built from an empty variable. set -u turns this into an immediate, traceable error.

3. pipefail "turns on a light" in pipelines that should allow failure. After enabling pipefail, watch all pipelines: cmd1 | cmd2 will fail if cmd1 fails. Some legacy pipelines (for example yes | head) deliberately make the left command stop with SIGPIPE — understand whether that's acceptable or needs || true.

4. Double set -e + cmd || exit 1. Writing cmd || exit 1 inside a script that already uses set -e isn't harmful, but it's often redundant. What's dangerous is writing cmd || { ... } and then forgetting the exit inside the block — the script continues with a wrong assumption. Always end a handling block with exit <code>.

5. Testing only in a smooth environment. A script tested on a laptop with all commands available and plenty of disk looks "strong" while actually being fragile. Test its failure paths: run it with a missing file, with denied permissions, with a full disk. Strict mode ensures failures are visible — your job is to provide a test environment where those failures genuinely happen.

Conclusion

In this episode 18 you've changed scripts from "assume everything works" to "demand evidence". You understand exit codes as the universal status language (0 success, 1-255 failure, $? to read the last value), &&/|| as one-line decision keywords, and the Unofficial BASH Strict Mode set -euo pipefail as a three-layer guard: stop on failure, reject undefined variables, and report failing pipelines. You also learned when to intentionally allow failure with cmd || true and if cmd; then, plus verifying results (test -s) before continuing.

The key takeaways:

  • Exit codes are how scripts communicate with cron, CI/CD, and supervisors — never lie by always exit 0.
  • set -euo pipefail on the second line of every script: failures become loud, not silent.
  • Commands in if, while, or after || don't trigger set -e — use that for commands that are allowed to fail.
  • set -e isn't a single safety net; validating results is still mandatory.

Now your scripts know when to stop. But there's a deeper question next: when the script stops, who cleans up? Temporary files created, locks held, half-built directories — all of them can be left behind when a script dies suddenly. In episode 19 we'll answer that with Signals Handling & Traps (Clean-up Operations): how to catch signals like SIGINT and SIGTERM, install trap to clean up on exit, and ensure your scripts never leave a mess behind. See you there!

Learn BASH Scripting - Error Handling & Robustness (Unofficial BASH Strict Mode) | Learn BASH Scripting