Module 5: Remote Machines, Networking, and Scripting

7. Logic and safety in scripts: conditionals, loops, and set -euo pipefail

Description

By the end of this lesson you will be able to write a script that decides instead of just running in a straight line: one that validates its arguments before doing anything, that checks whether a directory or a file exists before assuming it, that repeats an action over a list of items or over each line of a file, that ends with its own documented exit code depending on what happened, and that armors itself against silent failures with set -euo pipefail and cleans up its temporary files with trap no matter how it ends.

This is the difference between a script that "works on your machine, in the happy path" and one you can trust to a teammate or leave running unsupervised in a cron job. A script with no argument validation that receives unexpected input does not fail with a clear message — it fails three steps later, with a cryptic error from a command that should never have run, or worse, it runs all the way to the end and leaves something half-done without anyone finding out. A script with no error handling is the one that deletes the wrong file at three in the morning because a variable came in empty and nobody noticed until the next day.

Connection to the module: in the previous lesson you turned a sequence of commands into a .sh file that runs top to bottom, without making any decisions. Today you add exactly that: logic and safety. Everything you saw — positional parameters, quotes, $( ) — is still there, but now your script can ask "does this exist?", "which option did they ask for?", "do I need to repeat this over a list?", and respond with an exit code that another script (or you yourself, a month later) can read without guessing.


Decisions in the terminal: if, elif, else, and testing with [[ ]]

Think of a script with no conditionals as a recipe that assumes the pantry always has every ingredient: if one is missing, the recipe keeps cooking blindly and the result is a ruined dish, not a timely stop to announce "we are out of flour, I cannot continue." A conditional is exactly that pause: before continuing, the script checks a condition and decides which path to take.

The syntax in bash is:

if [[ condition ]]; then
  commands
elif [[ other_condition ]]; then
  commands
else
  commands
fi

elif and else are optional — you can have just an if, with its closing fi and nothing else. What you do need to understand is what goes inside [[ ]]. Bash has two ways to write a test: [ ] (the classic test command, inherited from the original Unix shell) and [[ ]] (a bash-specific keyword, newer). In modern bash scripts, use [[ ]]: it does not perform pattern expansion or word splitting on the variables inside it the same aggressive way [ ] does, and it lets you use && and || directly without the clunkier -a/-o syntax. Even so, it is still good practice to put variables in quotes inside [[ ]] too — you are going to see exactly why in this lesson's common mistakes.

Worked example

You are going to build a script that checks whether a release directory is ready to deploy: that the directory exists and that it has a configuration file inside it.

#!/usr/bin/env bash
#
# deploy-guard.sh — validates that a release directory has what it needs.
# Usage: ./deploy-guard.sh <directory>

target_dir="$1"

if [[ ! -d "$target_dir" ]]; then
  echo "Error: directory $target_dir does not exist"
elif [[ ! -f "$target_dir/deploy.conf" ]]; then
  echo "Error: missing $target_dir/deploy.conf"
else
  echo "Ready: $target_dir has what it needs to deploy."
fi

Run the script against a directory that does not exist:

chmod +x deploy-guard.sh
./deploy-guard.sh ghost-release

What to expect:

Error: directory ghost-release does not exist

Now create the directory but without the configuration file, and run it again:

mkdir -p release
./deploy-guard.sh release

What to expect:

Error: missing release/deploy.conf

The if was false (-d did find the directory), so bash moved on to evaluate the elif — and that is where it found the real problem. Complete the file and run it a third time:

touch release/deploy.conf
./deploy-guard.sh release

What to expect:

Ready: release has what it needs to deploy.

Neither condition failed, so bash reached the else. This script still does not help anyone but you staring at the screen — it does not tell apart "the directory was missing" from "the file was missing" with a code another program can read, and it does not even validate whether you gave it an argument at all. You fix that later in this same lesson.


Testing files, numbers, and strings: choosing the right comparison

Every type of data you might want to compare has its own test, and mixing them up is a constant source of subtle bugs. This table covers the ones you are going to use all the time:

TestTrue when...
-f filefile exists and is a regular file (not a directory, not a socket)
-d directorydirectory exists and is a directory
-z stringstring has zero length (it is empty)
-n stringstring has length greater than zero (it is not empty)
-x filefile exists and has execute permission for you

For numbers, the comparison does not use < or > — those operators, inside [[ ]], compare text strings alphabetically, not quantities. [[ 10 > 9 ]] evaluates to false, because as text the string "10" sorts before "9". To compare quantities you use these operators inherited from the original test:

OperatorMeaning
-eqequal to
-nenot equal to
-ltless than
-leless than or equal to
-gtgreater than
-gegreater than or equal to
if [[ "$retry_count" -ge 3 ]]; then
  echo "Already tried 3 times or more."
fi

For text strings, == and != do compare the full value, character by character:

if [[ "$environment" == "production" ]]; then
  echo "You are pointing at production."
fi

Option menus with case

A chained if/elif gets hard to read as soon as you have more than three or four possible options for the same variable — it is like a telephone switchboard where every call gets compared one by one against a long list of extensions, instead of looking at the dialed number and jumping straight to the right line. case does exactly that: it compares a value against a list of patterns and jumps to the first one that matches, without evaluating the rest.

case "$value" in
  pattern1)
    commands
    ;;
  pattern2|pattern3)
    commands
    ;;
  *)
    commands
    ;;
esac

Every branch ends with ;; (not fi, not elif) and the whole block closes with esac (case spelled backwards, the same convention bash uses to close iffi and dodone). The * pattern acts as a wildcard — it catches any value that did not match anything before it, the equivalent of a final else. The | between patterns lets you handle several inputs the same way without repeating the command block.

Worked example

Extend deploy-guard.sh so it receives a second action besides the directory:

#!/usr/bin/env bash
#
# deploy-guard.sh — validates or cleans a release directory.
# Usage: ./deploy-guard.sh <directory> {check|clean}

target_dir="$1"
action="$2"

case "$action" in
  check)
    if [[ ! -d "$target_dir" ]]; then
      echo "Error: directory $target_dir does not exist"
    elif [[ ! -f "$target_dir/deploy.conf" ]]; then
      echo "Error: missing $target_dir/deploy.conf"
    else
      echo "Ready: $target_dir has what it needs to deploy."
    fi
    ;;
  clean)
    echo "Cleanup mode — you complete it in the next section of this lesson."
    ;;
  *)
    echo "Unknown action: $action"
    echo "Usage: $0 <directory> {check|clean}"
    ;;
esac
chmod +x deploy-guard.sh
./deploy-guard.sh release check
./deploy-guard.sh release delete-everything

What to expect:

Ready: release has what it needs to deploy.
Unknown action: delete-everything
Usage: ./deploy-guard.sh <directory> {check|clean}

The first call matched the check branch and ran exactly the same if/elif/else logic from the previous section. The second one matched neither check nor clean, so it fell into the * wildcard — without you having to write an explicit comparison for every possible invalid input.


Repeating over lists and files: for and while read

When you need to do the same thing over several items, writing the same block of code once per item is exactly the kind of manual repetition a script exists to avoid. Bash has two different tools for this, and picking the wrong one is a common source of bugs: for when you already know what all the items are (a fixed list, or the files matching a pattern), and while read when you are reading a file's contents line by line and it can have any number of lines.

for over an explicit list:

for host in web-01 web-02 db-01; do
  echo "Checking $host..."
done

for over files matching a pattern (bash expands the pattern before the loop starts running):

for log_file in ./release/*.log; do
  echo "Found: $log_file"
done

while read to process a file line by line — useful when the file might have ten lines or ten thousand, and you neither want nor need to type them all out by hand in a for:

while IFS= read -r host; do
  echo "Processing $host"
done < hosts.txt

Three details of that line that are not decorative. -r in read keeps it from interpreting the backslash as an escape character inside each line — the same reason you already used it with read -p in the previous lesson. IFS= (empty, before read) keeps bash from trimming spaces at the start or end of each line, something read does by default and that can ruin a line that intentionally starts with a space. And < hosts.txt at the end — not a pipe (|) — feeds the loop directly from the file. This matters more than it looks: you are going to see exactly why in this lesson's common mistakes.

Worked example

Create a file with a list of hosts, one per line:

printf '%s\n' web-01 web-02 db-01 > hosts.txt

Now walk the file line by line, counting how many hosts you processed:

count=0
while IFS= read -r host; do
  echo "Verifying $host"
  count=$((count + 1))
done < hosts.txt
echo "Total hosts: $count"

What to expect:

Verifying web-01
Verifying web-02
Verifying db-01
Total hosts: 3

The count counter does keep its value after the loop ends — because the while ran in the same shell as the rest of the script, thanks to < hosts.txt. This exact pattern (a list of hosts, a loop that walks it one by one) is exactly what you are going to use in the module's final project to connect over SSH to every machine in a list, so it is worth having it down solid from here on.


Ending with intent: exit and documented exit codes

In this module's network troubleshooting lesson you already practiced a key idea: a status code is not a binary "works or does not work" verdict — it is information you have to read (a 301 is not an error, it is a valid HTTP response saying "the resource moved"). A process exit code is the same idea applied to scripts. Every command that runs in your terminal ends with a number between 0 and 255, stored in the special variable $?, which only exists until you run the next command. By convention — not by any operating system requirement, but by agreement between programs — 0 means success and any nonzero value means some kind of failure. Three codes already come reserved by bash itself: 126 (found the file but could not execute it, typically a permissions issue), 127 (the command does not exist), and 128 + N (the process died from signal number N; for example 130 is 128 + 2, and signal 2 is the one Ctrl-C sends).

Your script can also choose its own codes with exit N, and the part that really matters is documenting them, not just choosing them. Without documentation, an exit code of 4 tells nobody anything; with a comment at the top of the script saying what each number means, it becomes information another script — or a teammate, or you in six months — can read and act on without having to open the source code.

#!/usr/bin/env bash
#
# deploy-guard.sh
# Exit codes:
#   0 — everything is fine
#   2 — incorrect usage (missing arguments)
#   3 — the directory does not exist
#   4 — the configuration file is missing

target_dir="$1"

if [[ ! -d "$target_dir" ]]; then
  echo "Error: directory $target_dir does not exist" >&2
  exit 3
elif [[ ! -f "$target_dir/deploy.conf" ]]; then
  echo "Error: missing $target_dir/deploy.conf" >&2
  exit 4
fi

echo "Ready: $target_dir has what it needs to deploy."
exit 0

Two new details there. exit N ends the script immediately with that code — no line after an exit that gets reached ever runs. And error messages now go to >&2 (standard error) instead of standard output — that way, whoever uses your script can separate diagnostic messages from actual results, for example by redirecting 2>/dev/null if they only care about the clean output.


Armoring the script: set -euo pipefail, flag by flag

A script with no set -euo pipefail is like a house with no circuit breaker: if something short-circuits, electricity keeps flowing until the problem turns into a fire, instead of cutting off the instant something abnormal happens. These three flags, turned on together at the top of the script, turn failures that would otherwise keep running silently into an immediate, loud stop.

set -euo pipefail

-e (errexit). If any command ends with a nonzero code, the script stops right there, instead of moving on to the next line as if nothing happened. It has important exceptions worth knowing: it does not apply to a command you are evaluating inside the condition of an if, while, or until (there the failure is information, not an accident), it does not apply to the command before an && or || in a chain (only the last one in the chain matters), and it does not apply to a command negated with !.

-u (nounset). Referencing a variable that was never defined stops being a silent empty string and becomes an error that halts the script. This catches the typical typo ($hots_file instead of $hosts_file) the moment it happens, not several lines later once the empty value already caused another problem.

-o pipefail. Without this flag, a pipe like command_a | command_b only reports command_b's exit code — if command_a fails but command_b still runs and ends with 0, the whole pipe gets reported as successful. With pipefail turned on, the exit code for the whole pipe is that of the last command that failed inside it, not necessarily the last one by position.

Worked example

Now the known case where -e does not protect you, even with all three flags on. This bug is real and well documented: when you declare and assign a local variable on the same line, the exit code that matters is local's, not the command's you gave it inside.

#!/usr/bin/env bash
set -euo pipefail

check_disk() {
  local result="$(df -h /nonexistent-mount-point 2>/dev/null)"
  echo "Result: $result"
}

check_disk
echo "If you see this line, set -e did not protect you from df failing."
chmod +x check-disk.sh
./check-disk.sh

What to expect:

Result: 
If you see this line, set -e did not protect you from df failing.

df against a path that does not exist fails with a nonzero code — but local result="$(df ...)" is, to bash, a single statement whose final exit code is local's, and local itself always succeeds (0) as long as the variable declaration is valid. df's failure gets masked, and the script keeps running as if nothing happened. The fix is to split the declaration and the assignment into two lines:

check_disk() {
  local result
  result="$(df -h /nonexistent-mount-point 2>/dev/null)"
  echo "Result: $result"
}

With the assignment on its own line, its exit code is no longer covered up by local's, and set -e does stop the script exactly there — you never get to see the echo afterward. This is exactly the kind of silent failure set -euo pipefail promises to prevent, but that a syntax detail can sneak back in if you do not know it exists.


trap: automatic cleanup on exit

Think of trap as a note taped to the door that says "turn off the lights on your way out" — no matter which door you leave through: the front one, the emergency exit, or if someone dragged you out shouting. trap registers a command that bash runs right before the script ends, no matter the reason: it reached the end, someone called exit, or set -e stopped it because of a failure. This is exactly what you need for temporary files: if you only delete them on the script's last line, any early exit — by mistake or by design — leaves them lying around.

tmp_report="$(mktemp)"
trap 'rm -f "$tmp_report"' EXIT

mktemp creates an empty temporary file with a unique name (typically inside /tmp) and hands you back its path — so you never run the risk of overwriting an existing file by picking the name yourself. trap 'command' EXIT registers command to run on exit; it uses single quotes so $tmp_report expands at the moment the trap actually fires, not at the moment you register it (in this case it does not matter because the variable already has its final value, but it is the right habit in case the variable could change later in the script).

Worked example

#!/usr/bin/env bash
set -euo pipefail

tmp_report="$(mktemp)"
trap 'rm -f "$tmp_report"' EXIT

echo "Writing temporary report to $tmp_report"
printf 'host,status\nweb-01,ok\n' > "$tmp_report"
cat "$tmp_report"

# Failure forced on purpose, to verify cleanup still runs.
false
echo "This line never runs."
chmod +x report-demo.sh
./report-demo.sh; echo "Exit code: $?"

What to expect (the exact temporary file path is going to vary on your machine):

Writing temporary report to /tmp/tmp.a1B2c3D4e5
host,status
web-01,ok
Exit code: 1

The script never reached the last echofalse always fails, and with set -e on that stopped execution right there, with exit code 1. But the temporary file no longer exists: confirm it with ls "$tmp_report" (it is going to fail with "No such file or directory," because bash already lost the value of $tmp_report once the script ended — but if you saved the path before running it, you are going to see it indeed disappeared). The trap ran at the exact instant the script died from false failing, without you having to remember to clean up manually at every possible exit point.


Validating arguments and offering --help

With -u on, a detail shows up that surprises people the first time: if your script received no arguments, typing $1 alone triggers the "unbound variable" error you turned on yourself, before you can even show the user the correct usage. That is why argument validation almost always starts with ${1:-} (expands to an empty string if $1 does not exist, instead of failing) rather than $1 alone.

Put everything from this lesson together into a complete version of deploy-guard.sh:

#!/usr/bin/env bash
#
# deploy-guard.sh — validates or cleans a release directory.
# Usage: ./deploy-guard.sh <directory> {check|clean|help}
# Exit codes:
#   0 — everything is fine (or --help was explicitly requested)
#   2 — incorrect usage (missing argument, or unrecognized action)
#   3 — the directory does not exist
#   4 — the configuration file is missing

set -euo pipefail

readonly EXIT_USAGE=2
readonly EXIT_NO_TARGET=3
readonly EXIT_NO_CONFIG=4

usage() {
  printf '%s\n' \
    "Usage: $0 <directory> {check|clean|help}" \
    "  check  validates that the directory and deploy.conf exist (default option)" \
    "  clean  deletes the .log files inside the directory" \
    "  help   shows this message"
}

if [[ "${1:-}" == "-h" ]] || [[ "${1:-}" == "--help" ]]; then
  usage
  exit 0
fi

if [[ $# -lt 1 ]]; then
  echo "Error: missing the release directory." >&2
  usage
  exit "$EXIT_USAGE"
fi

target_dir="$1"
action="${2:-check}"

lock_file="$(mktemp)"
trap 'rm -f "$lock_file"' EXIT

case "$action" in
  check)
    if [[ ! -d "$target_dir" ]]; then
      echo "Error: directory $target_dir does not exist" >&2
      exit "$EXIT_NO_TARGET"
    elif [[ ! -f "$target_dir/deploy.conf" ]]; then
      echo "Error: missing $target_dir/deploy.conf" >&2
      exit "$EXIT_NO_CONFIG"
    else
      echo "Ready: $target_dir has what it needs to deploy."
    fi
    ;;
  clean)
    for log_file in "$target_dir"/*.log; do
      [[ -e "$log_file" ]] || continue  # no real .log files, the pattern is left unexpanded
      echo "Deleting $log_file"
      rm -f "$log_file"
    done
    ;;
  help)
    usage
    ;;
  *)
    echo "Unknown action: $action" >&2
    usage
    exit "$EXIT_USAGE"
    ;;
esac

readonly freezes each exit code as soon as it is defined: if someone later in the script accidentally reassigns EXIT_USAGE, bash warns with an error instead of letting the value change silently. ${2:-check} applies the same idea as ${1:-}, but also gives it a default value (check) when you were not passed a second action, instead of failing. And in the for loop over *.log, [[ -e "$log_file" ]] || continue is necessary because, if the directory has no .log files at all, bash (without the nullglob option, which is not on by default) leaves the pattern unexpanded instead of an empty list — without that check, your script would try to delete a literal file called *.log that does not exist.

Test it with three scenarios:

chmod +x deploy-guard.sh
./deploy-guard.sh
./deploy-guard.sh ghost-release check
mkdir -p release && touch release/deploy.conf
./deploy-guard.sh release check

What to expect (check $? after each call if you want to confirm the exact code):

Error: missing the release directory.
Usage: ./deploy-guard.sh <directory> {check|clean|help}
  check  validates that the directory and deploy.conf exist (default option)
  clean  deletes the .log files inside the directory
  help   shows this message
Error: directory ghost-release does not exist
Ready: release has what it needs to deploy.

Three calls, three different results, each with its own exit code documented in the header — exactly what another script (or a CI pipeline) needs to react based on what happened, instead of just seeing a wall of text and guessing.


When the script has already grown too big: the boundary with Python, and shellcheck as a safety net

Bash is excellent for orchestrating existing commands — calling programs, chaining their output, making simple decisions about files and strings. It stops being the right tool when the problem stops being "orchestrate commands" and starts being "actually program." These are concrete signs it is time to rewrite in Python:

  • The script passes around 150 lines or a handful of functions — following the flow at a glance gets hard, and bash does not have good module support to split it into smaller pieces.
  • You need real data structures — a dictionary mapping host to status, a list of objects with several fields — not just text strings and flat, space-separated lists.
  • You need to parse JSON, make several HTTP calls with retries and error handling, or any logic that in bash ends up being an increasingly fragile chain of curl + grep + sed.
  • You want automated tests for your own logic. Bash has tools for this (bats, for example), but they are much less natural than writing a test with pytest.
  • The logic is no longer "a sequence of steps with a few decisions," but business rules with several levels of nesting, retries with growing backoff, or rate limits.

None of these signs mean bash is "bad" — it means the problem changed shape, and the tool should change with it.

As long as your script stays bash, shellcheck is the cheapest safety net there is: a static analyzer that reads your script and points you to the exact line where something can fail, with an explanation and, almost always, the suggested fix.

shellcheck deploy-guard.sh

What to expect if, for example, some line in the script had an unquoted variable (echo $target_dir instead of echo "$target_dir"):

In deploy-guard.sh line 12:
echo $target_dir
     ^-- SC2086 (info): Double quote to prevent globbing and word splitting.

Did you mean:
echo "$target_dir"

Every warning carries a code (SC2086 in this case) you can look up in the shellcheck wiki if you want the full detail of why it matters. Running shellcheck on every script before calling it done costs seconds and catches exactly the kind of quoting and variable mistake you already saw in this lesson and the previous one.


Common mistakes

"With set -euo pipefail on, I do not have to worry about anything anymore — any failure is going to stop the script." What happens: local result="$(failing_command)" does not stop the script even if failing_command ends with a nonzero code. Why it happens: bash reports the exit code for the whole local var=value statement, which is local's own code (always 0 if the declaration is valid), not the code of the command inside the substitution — the real failure gets masked before set -e ever gets a chance to react. How to spot it: if a script with set -e keeps running after a command you know should have failed, check whether that assignment is combined with local (or export, or declare) on the same line. How to fix it: split the declaration and the assignment into two lines (local result and then result="$(command)" separately), so the command's exit code does not get covered up.

"I put the while read inside a pipe so I would not have to save an intermediate file, and my counter came out at zero at the end." What happens: cat file | while read -r line; do count=$((count + 1)); done correctly increments count inside the loop, but outside the loop it goes back to whatever it was before (typically 0, or fails outright if you had set -u). Why it happens: every link in a pipe runs in its own subshell — a copy of the shell process, with its own variables, that disappears as soon as that stage of the pipe finishes. The while does see and modify its copy of count while it runs, but that copy never makes it back to the main shell. How to spot it: if a variable you modify inside a while read | while seems to "reset" right after the done, suspect a pipe before the loop. How to fix it: feed the while with file redirection (done < file) instead of a pipe — that way the while runs in the same shell as the rest of the script, exactly like in this lesson's worked example in the loops section.

"I compared two strings with [[ "$var" == $expected ]] with no quotes on the right side, and got a result I did not expect." What happens: if $expected contains a special pattern character like * or ?, [[ ]] does not do a literal text comparison — it does pattern matching (glob), the same logic bash uses to expand *.log. Why it happens: inside [[ ]], the right side of == (and of !=) gets treated as a pattern when it is not quoted, not as fixed text. How to spot it: the comparison gives an unexpected result exactly when the value contains *, ?, or [ — with "normal" text you never notice, because there are no pattern characters to interpret. How to fix it: quote the right side ([[ "$var" == "$expected" ]]) whenever you want a literal text comparison instead of pattern matching.


Exercises

1. Four tests, one script

Write backup-check.sh <directory> with these exit codes documented in a header comment: 0 everything is fine, 2 missing argument, 3 the directory does not exist, 4 no backup.sh inside it, 5 backup.sh exists but is not executable. Use if/elif/else with -d, -f, and -x.

See solution
#!/usr/bin/env bash
#
# backup-check.sh — validates that a directory has an executable backup.sh.
# Usage: ./backup-check.sh <directory>
# Exit codes: 0 ok, 2 missing argument, 3 directory does not exist,
#             4 backup.sh missing, 5 backup.sh is not executable.

set -euo pipefail

if [[ $# -lt 1 ]]; then
  echo "Error: missing the directory to validate." >&2
  echo "Usage: $0 <directory>"
  exit 2
fi

target_dir="$1"

if [[ ! -d "$target_dir" ]]; then
  echo "Error: directory $target_dir does not exist" >&2
  exit 3
elif [[ ! -f "$target_dir/backup.sh" ]]; then
  echo "Error: missing $target_dir/backup.sh" >&2
  exit 4
elif [[ ! -x "$target_dir/backup.sh" ]]; then
  echo "Error: $target_dir/backup.sh exists but is not executable" >&2
  exit 5
else
  echo "OK: $target_dir has an executable backup.sh."
fi

Why it works: each elif only gets evaluated if the previous one was false, so the four conditions get checked in order of increasing specificity (first that the directory exists, then that the file inside it exists, then that it is executable), and each one exits with a different code documented in the header — whoever calls the script can react differently depending on which of the four numbers it got back.

2. A menu with case

Write service-ctl.sh {start|stop|status|help} that prints a different message for start, stop, and status, shows the usage for help, and for any other input prints an error to stderr with the action it received, shows the usage, and ends with code 2.

See solution
#!/usr/bin/env bash
#
# service-ctl.sh — controls a fictional service by action name.
# Usage: ./service-ctl.sh {start|stop|status|help}

set -euo pipefail

usage() {
  echo "Usage: $0 {start|stop|status|help}"
}

action="${1:-}"

case "$action" in
  start)
    echo "Starting service..."
    ;;
  stop)
    echo "Stopping service..."
    ;;
  status)
    echo "The service is running."
    ;;
  help)
    usage
    ;;
  *)
    echo "Unknown action: $action" >&2
    usage
    exit 2
    ;;
esac

Why it works: case compares $action against each pattern in order and jumps to the first branch that matches, without evaluating the rest; the * wildcard catches any value that did not match any of the three valid actions or help, exactly like a final else but without needing an explicit chain of comparisons.

3. The failure set -e did not stop

This snippet runs with set -euo pipefail on and, against what you would expect, prints "Report generated" even if generate_report fails:

generate_report() {
  local output="$(curl -sf https://api.example.com/report)"
  echo "Report generated"
}

Why does this happen, and how do you fix it?

See solution

The exit code bash observes for that line is local's, not the curl inside the command substitution's. local var=value is a single statement to bash, and local itself succeeds (code 0) as long as the variable can be declared — regardless of whether the command that produced value failed. curl -sf's failure (which returns nonzero if the server responds with an error) gets masked, and set -e never finds out.

The fix is to separate the declaration from the assignment:

generate_report() {
  local output
  output="$(curl -sf https://api.example.com/report)"
  echo "Report generated"
}

Why it works: by moving the assignment to its own line, its exit code no longer competes with local's — it is the exit code for that whole statement, and set -e does detect it and stops the script before reaching the echo.

4. The counter that resets

This snippet prints Total: 0 no matter how many lines servers.txt has:

count=0
cat servers.txt | while read -r server; do
  count=$((count + 1))
done
echo "Total: $count"

Why does this happen, and how do you fix it without adding any new intermediate file?

See solution

Every command in a pipe (cat servers.txt | while ...) runs in its own subshell — a separate copy of the bash process. The while does correctly increment its copy of count while processing each line, but that copy lives and dies inside the pipe's subshell; the main shell's count, the one the final echo reads, never finds out about those changes and stays at 0.

The fix is to replace the pipe with file redirection, so the while runs in the same shell as the rest of the script:

count=0
while read -r server; do
  count=$((count + 1))
done < servers.txt
echo "Total: $count"

Why it works: < servers.txt feeds the loop directly, with no new subshell created — the while runs in the main shell, so every count=$((count + 1)) modifies the same variable the final echo is going to read.


Summary and next step

Before moving on you should be able to:

  • write if/elif/else with [[ ]] and choose the right test (-f, -d, -z, -x, -eq, ==) based on the type of data you are comparing;
  • build an option menu with case, its ;;, and its * wildcard;
  • iterate with for over a list or a file pattern, and read a file line by line with while read without falling into the pipe-subshell trap;
  • end a script with exit and a code of your own, documented in a comment, instead of letting bash decide by default;
  • explain what each flag in set -euo pipefail prevents, and describe from memory the real case where -e does not protect you (a local variable assigned with a failing command substitution);
  • use trap ... EXIT to guarantee a temporary file gets deleted no matter how the script ends, and explain why ${1:-} is safer than plain $1 under set -u;
  • recognize at least three concrete signs that a script has already grown too big for bash, and run shellcheck as your first line of defense while it stays bash.

Everything you just practiced — argument validation, set -euo pipefail, trap, and a documented exit code per failure type — is exactly the skeleton you are going to use in the next lesson to build the final project's three scripts: check-env.sh, probe.sh, and remote-report.sh. None of the three needs new logic; they are these same patterns applied to a real remote-diagnostics problem, with SSH, rsync, and the network commands you already saw earlier in this module.


Resources