Module 5 — Conditionals: if, test, and case

Updated 3 September 2026

Module 5 — Conditionals: if, test, and case. Scripts that decide — check a file exists before deleting, check a service is up before deploying. This is where scripts stop being command lists and start being programs.

🧠 concept → 🧩 analogy → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (hidden)

Before you start. You need Modules 1–4, and especially Module 3: if is built directly on exit codes, and this module makes no sense without them. Also load-bearing: Module 2's quoting reflex (one exercise here shows exactly what it prevents) and Module 4's >&2. Tools: nothing new. Keep ~/bash-course with users.txt.

Part A — if: a verdict reader with a body

A1. if runs a command and reads its exit code — that is the whole trick

Here is the fact that makes bash conditionals click, and that most tutorials bury: if does not evaluate a "condition." It runs a command and reads its exit status. Verdict 0 → the then block runs; any other verdict → it doesn't. The full grammar:

bash
if some-command; then
    commands-for-success
elif other-command; then      # optional, as many as you like
    commands-if-THAT-succeeded
else                          # optional
    commands-if-everything-failed
fi                            # "if" backwards — closes the block

Any command can stand guard. if grep -q bob users.txt — grep's verdict decides. if ./preflight.sh — your own script's exit code (Module 3 C2) decides; that is why truthful verdicts mattered. And ! in front of a command flips its verdict: if ! grep -q zoe users.txt reads "if zoe is not in the file." Compare this with Module 3's &&: same verdict-reading machinery, but if binds its else to the one command you name — which is exactly the fix for the a && b || c trap.

Real-world analogy — the bouncer. if posts a bouncer at a door: the bouncer runs one check ("on the list?") and the answer — yes or no, nothing in between — decides which corridor you walk down. The check can be trivial or elaborate (a whole preflight script), but it must compress to one verdict, because doors only open or close.

Where the analogy stops working. A bouncer evaluates you; if evaluates nothing — it delegates entirely. There is no "condition language" hiding inside if; the [ ... ] you have seen in other people's scripts is not if-syntax, it is a separate command with its own verdict, and Part B introduces it as exactly that. Grasping this dissolves half of bash's apparent weirdness in advance.

🧪 Exercise 5.1 — commands as conditions
bash
cd ~/bash-course
if grep -q bob users.txt; then
    echo "bob is on the list"
fi
if ! grep -q zoe users.txt; then
    echo "zoe is not"
fi
if grep -q zoe users.txt; then
    echo "zoe found"
else
    echo "no zoe; adding a placeholder note" >&2
fi
Expected result — click to reveal
plain text
bob is on the list
zoe is not
no zoe; adding a placeholder note

What to read out of it: three shapes of the same machine. Shape 1: verdict 0 → then-block. Shape 2: ! flipped grep's 1 into success — "absence" became a positive condition. Shape 3: a real else-branch, bound unambiguously to the one grep named after if — no a && b || c ambiguity possible. Note the else-branch's message goes out >&2 (Module 4 B2): it is commentary, not results. These three shapes, plus Part B's test command, cover the vast majority of all conditionals you will ever read.

Interview questions — Part A

🎯 "How do you use conditional statements in Bash?" — asked verbatim at Zero To Mastery; KnowledgeHut asks "How do you use conditional statements (e.g., if, case) in a shell script?"

The direct answer: if command; then … elif …; then … else … fi — the branch taken depends on each guard command's exit status, with 0 selecting the then-block. case (Part D) handles one-value-many-patterns branching.

Going deeper: lead with the mechanism — if runs a command, not a condition; [ and [[ are just commands whose job is producing verdicts. That framing explains everything else: why any command works as a guard, why if ./deploy.sh is idiomatic, why ! negates.

The details that separate candidates: demonstrating a guard that is not a bracket (if grep -q…, if ping -c1 host) — interviewers specifically probe whether you think brackets are mandatory; and contrasting if/else with && … || one-liners, including why the one-liner is not a safe else (Module 3's trap, cited by name).

Part B — test, alias [: the verdict factory

B1. [ is a command. Its name is one square bracket. Really.

if reads verdicts, but plenty of questions have no ready-made command: is this string empty? is that a directory? is x greater than 3? The answer is a command whose entire purpose is turning small questions into exit codes: test. test -f /etc/hosts exits 0 if that file exists and is a regular file; 1 if not. And test has a famous disguise: the command [ — same program, but when invoked by its bracket name it demands a matching ] as its final argument, purely so your code reads like syntax:

bash
if [ -f /etc/hosts ]; then echo "found"; fi

Because [ is a command, every rule you already know applies with full force. The spaces are mandatory[-f is a Module-1 PATH search for a command named [-f (verdict: 127); the bracket must stand alone. Everything between [ and ] is arguments, so Module 2's expansion-then-splitting happens to them — which loads the gun for the trap in B3.

Counter-intuitive: [ ] is not punctuation, not syntax, not part of if — it is a program with a one-character name (run type [ and bash will tell you: [ is a shell builtin; there is even a /usr/bin/[ on most systems). Every "weird" rule about brackets follows mechanically: spaces required (arguments need separating), quoting matters inside (they are arguments), it can run without if ([ -f x ] && echo yes — it's just a verdict feeding Module 3's glue), and ] is merely the argument it insists on last. Nothing else in this module needs memorizing once this sinks in.
Real-world analogy — the inspector with a stamp. test/[ is a building inspector: hand over a claim ("this is a directory," "these strings match") and the inspector checks it and stamps PASS (0) or FAIL (1). The if-statement is just the manager acting on stamps. The [ costume exists because paperwork reads better when the inspection is visually bracketed — but under the costume it is the same inspector, and he only reads the documents handed to him.

Where the analogy stops working. Hand a human inspector a form with a missing field and he asks about it. Hand [ a missing argument — which is precisely what an unquoted empty variable becomes after word splitting — and he does not see a blank field; he sees a different, shorter form and misjudges or errors on that (exit 2 and a message like unary operator expected). B3 springs this trap live.

🧪 Exercise 5.2 — meet the inspector

The last command fails on purpose.

bash
type test
type [
[ -f /etc/hosts ] ; echo "$?"
[ -f /etc/nosuchfile ] ; echo "$?"
[-f /etc/hosts] ; echo "$?"     # ← spaces removed: not brackets anymore
Expected result — click to reveal (contains a deliberate failure)
plain text
test is a shell builtin
[ is a shell builtin
0
1
bash: [-f: command not found
127

What to read out of it: bash itself testifies that both names are builtins — commands, not syntax. The two bracketed runs produce clean verdicts: 0 (exists) and 1 (doesn't) — usable naked with &&/|| or dressed in if. The finale is Module 1 crashing the party: remove the space and bash searches PATH for a command literally named [-f — exit 127, command not found. If you ever see that error pointing at something bracket-shaped, you now diagnose it in one second: missing space, nothing deeper.

B2. The questions the inspector can check

The inspector's form catalogue, organized by what is being inspected. Files:

TestPASS (exit 0) when…Typical production use
-e paththe path exists (any type)"is there anything at this path?"
-f pathexists and is a regular fileconfig present before reading it
-d pathexists and is a directorytarget dir present before writing into it
-r / -w / -x pathyou may read / write / execute itpermission preflight (Module 3 Ticket 4's guard)
-s pathexists and is not empty"did the backup actually contain bytes?"

Strings — where -z asks "is it empty?" and -n "is it non-empty?", and = / != compare two of them. Numbers — a separate operator family: -eq -ne -gt -ge -lt -le. Two families because text and numbers disagree about the world: as strings, "10" < "9" is TRUE (character 1 sorts before 9, like dictionary order); as numbers, obviously false. Use = for strings, -eq for integers, and never trust a comparison until you know which family it is running in.

🧪 Exercise 5.3 — files, strings, numbers
bash
cd ~/bash-course
[ -s users.txt ] && echo "users.txt has content"
[ -x hello.sh ] && echo "hello.sh is executable"
x=10
[ "$x" -gt 9 ] && echo "numerically: 10 > 9, of course"
[ "$x" \< "9" ] && echo "as STRINGS: 10 sorts before 9"   # \< escapes < from Module 4's redirection meaning
name=""
[ -z "$name" ] && echo "name is empty"
Expected result — click to reveal
plain text
users.txt has content
hello.sh is executable
numerically: 10 > 9, of course
as STRINGS: 10 sorts before 9
name is empty

What to read out of it: the shock line is the fourth — the same value, compared in the string family, sorts the "wrong" way, PASSes, and prints. Nothing malfunctioned: dictionary order says 10 precedes 9 because 1 precedes 9. Version strings are where this bites production ("10.2.0" < "9.9.9" as strings!). Note also the \< — inside [ ], a bare < would be Module 4's input redirection, because [ is a command being handed arguments; the backslash (Module 2's third quoting tool) keeps it literal. [[ in Part C removes that particular awkwardness.

B3. The unquoted-variable trap, sprung on purpose

Module 2 promised the quoting reflex would keep paying off. Here is its finest hour. [ $name = "alice" ] with name empty: expansion replaces $name with nothing, word splitting removes the nothing, and the inspector receives the form [ = alice ] — three arguments where four were expected. He does not know a field is missing; he errors on the form he got.

🧪 Exercise 5.4 — the vanishing argument

The first test fails on purpose — with an error, not a verdict.

bash
name=""
[ $name = "alice" ] ; echo "$?"      # ← unquoted empty variable vanishes
[ "$name" = "alice" ] ; echo "$?"    # quoted: empty string stays a (empty) argument
Expected result — click to reveal (contains a deliberate failure)
plain text
bash: [: =: unary operator expected
2
1

What to read out of it: the unquoted version did not return "false" — it returned 2 with a syntax complaint, the inspector's "this form is malformed." (unary operator expected = "I got = alice ] and thought = must be a one-argument test, which it isn't.") The quoted version returns a clean, meaningful 1: checked, not equal. The difference matters enormously in if: both non-zero verdicts skip the then-block, so the buggy script often appears to work — while spraying errors to stderr and conflating "malformed check" with "checked: no." Quote every expansion inside [ ], no exceptions — this is Module 2's reflex with a badge.

Now imagine this at 500 hosts. The empty variable that springs this trap is rarely typed — it arrives: a config field missing on 3 hosts of 500, an API returning blank, a $(command) that failed silently (Module 2's runner with an empty slip). On those 3 hosts your health check now exits 2 instead of 0/1, your if misroutes, and stderr fills with unary operator expected — a message that names neither the variable nor the cause. Fleet practice therefore stacks three defenses from three modules: quote inside brackets (this module), set -u to make unset expansion fatal (Module 14), and ShellCheck in CI, which flags unquoted test arguments (SC2086) before they ever ship.

Interview questions — Part B

🎯 "What is the difference between == and -eq in shell scripting?" — asked verbatim at Hirist

The direct answer: == (and POSIX =) compares strings; -eq compares integers. [ "10" == "10" ] and [ 10 -eq 10 ] both pass, but [ "010" == "10" ] fails (different text) while [ 010 -eq 10 ] passes (same number).

Going deeper: give the ordering hazard, not just equality — string < is dictionary order, so "10" < "9" is true, which is how version comparisons go wrong; and note -eq on a non-number is an error (exit 2, integer expression expected), not false. Mention that inside [ ], = is the portable spelling and == a bash-ism; inside [[ ]], == additionally does pattern matching against an unquoted right side.

The details that separate candidates: the exit-2-vs-exit-1 distinction (malformed test vs false test) and its debugging signature; plus the honest answer to "how do I compare versions properly?" — not with either family; use sort -V (Module 11) or dedicated tooling.

🎯 "How to check if a directory exists?" — asked verbatim at Edureka; Zero To Mastery asks "How do you check for file or directory existence in Bash?"

The direct answer: if [ -d "$dir" ]; then … — with -f for regular files, -e for "anything at this path."

Going deeper: quote the variable (Edureka's own published answer writes [ -d $mydir ] unquoted — you can now explain precisely why that is a bug waiting for an empty or spacey value); pick the right operator deliberately (-e passes for a directory where you needed a file — a real class of bug); and add the permission dimension: existence is not access; -r/-w/-x check what you may actually do.

The details that separate candidates: the negative forms in guard style — [ -d "$dir" ] || { echo "missing: $dir" >&2; exit 1; } — showing conditionals fused with Module 3's guard-clause idiom and Module 4's stderr discipline in one production-shaped line.

Part C — [[ … ]]: the inspector who works for bash directly

C1. What changes inside double brackets

Bash offers a second inspector: [[ … ]]. Unlike [, this one is not a command — it is real syntax, parsed by bash before the usual rewriting machinery runs. That single design change buys four practical upgrades:

1. The vanishing-argument trap is disarmed. No word splitting, no globbing happen between [[ and ]][[ -n $file ]] is safe even unquoted, even with spaces in the value. (Keep quoting anyway; the reflex must survive contact with [-style code, and quoted is never wrong.)

2. < and > compare strings without backslashes. They cannot mean redirection here, because bash knows it is inside a test.

3. == pattern-matches. With an unquoted right-hand side, [[ "$host" == web-* ]] asks "does the value fit this shape?" — Module 2's glob language, repurposed for strings. Quote the right side and it degrades to plain equality.

4. =~ matches regular expressions. [[ "$input" =~ ^[0-9]+$ ]] — "entirely digits" — is the standard numeric-input validator. (Regex is Module 11's language; until then, treat this one pattern as a recipe.)

The cost: [[ ]] is bash-only. In #!/bin/sh scripts (Module 1 C2's territory) it does not exist — dash greets it with [[: not found. The professional rule: [[ ]] in bash scripts, [ ] when writing portable sh — and never a mix out of habit.

Real-world analogy — the in-house inspector. [ is a contractor: you mail him documents, and the mailroom (bash's expansion pipeline) can lose a blank page en route — he judges whatever arrives. [[ is an inspector employed inside the building: he walks to the source and reads the originals before the mailroom touches anything. Same checklists, plus a few extras only an insider can offer (pattern matching against shapes, not just values).

Where the analogy stops working. In-house convenience creates a monoculture: the insider exists only in this building (bash). Send your script to a building staffed differently (/bin/sh on Ubuntu = dash) and every [[ line dies with not found. The contractor, for all his mailroom risk, works everywhere — which is exactly the portability trade from Module 1 C2, recurring at the level of a single bracket.

🧪 Exercise 5.5 — the upgrades, live
bash
file="monthly report.txt"
[[ -n $file ]] && echo "unquoted, yet safe (in [[ only!)"
[[ "10" < "9" ]] && echo "string order still says 10 < 9"
host="web-42"
[[ "$host" == web-* ]] && echo "pattern: a web host"
input="123"
[[ "$input" =~ ^[0-9]+$ ]] && echo "input is numeric"
Expected result — click to reveal
plain text
unquoted, yet safe (in [[ only!)
string order still says 10 < 9
pattern: a web host
input is numeric

What to read out of it: line 1 would have been the B3 explosion under [ ] — here it just works: the space in the value never became an argument boundary. Line 2 is a warning shot: [[ fixes splitting, not semantics< is still dictionary order, so the 10-before-9 surprise survives. Lines 3–4 are the genuinely new powers: shape-matching with globs and regex. One habit to carry out of this exercise: pattern right sides stay unquoted on purpose (web-*), value right sides get quotes — in [[, quoting the right side is how you say "literal, not pattern."

Interview questions — Part C

🎯 "What is the difference between [ and [[ in bash?" — the classic screener; rarely published with exact wording, but it is the substance behind Zero To Mastery's and KnowledgeHut's conditional questions, and whole reference articles exist on it

The direct answer: [ is a command (alias of test, POSIX-portable) whose arguments undergo normal expansion — quoting is critical. [[ is bash syntax: no word splitting or globbing inside, </> compare strings directly, == pattern-matches an unquoted right side, =~ matches regex — but it does not exist in plain sh.

Going deeper: demonstrate the divergence with one empty variable — [ $x = y ] errors (unary operator expected, exit 2) where [[ $x = y ]] calmly returns false — and explain why: arguments-after-expansion versus parsed-before-expansion.

The details that separate candidates: the decision rule stated as policy (bash script → [[; #!/bin/sh[ with religious quoting); knowing &&/|| work inside [[ ]] but not inside [ ] (there you need -a/-o, which are deprecated and ambiguous); and the regex caveat that the =~ pattern should be unquoted or stored in a variable — quoting it makes it literal.

🎯 "How do you validate numeric input in shell scripts?" — asked verbatim at Hirist

The direct answer: [[ "$input" =~ ^[0-9]+$ ]] — anchored regex, "one or more digits and nothing else." Non-numeric (including empty) fails the match, and you reject with a usage message on stderr and exit 2 (Module 3's convention).

Going deeper: explain why the naive alternative is wrong — [ "$input" -eq "$input" ] "works" by exploiting the error behavior of -eq on non-numbers (exit 2 plus stderr noise), which conflates malformed-with-false and pollutes logs. The regex states intent; anchors ^/$ are the part candidates forget (unanchored [0-9] happily accepts abc123).

The details that separate candidates: edge-case awareness — negative numbers (^-?[0-9]+$), leading zeros (later arithmetic may parse them as octal), and empty-string behavior; plus where validation belongs: at the top of the script, on arguments, before any work (the fail-fast shape Module 7 completes).

Part D — case: one value, many shapes

D1. The switchboard

When one value must route to one of many branches — an environment name, a subcommand, a $rc from Module 3 — an elif-ladder works but reads like bureaucracy. case is the purpose-built switchboard:

bash
case "$env" in
    prod|production)  echo "deploying carefully" ;;
    staging)          echo "deploying to staging" ;;
    dev*)             echo "anything goes" ;;
    *)                echo "unknown environment: $env" >&2 ; exit 2 ;;
esac

The left sides are patterns (the same glob language as [[ ... == ... ]] — literals, *, ?, and | for alternatives), tried top to bottom; the first match wins and only its branch runs. ;; closes a branch; esac (case backwards) closes the construct; and the final *) is the switchboard's "all other callers" jack — in production it is mandatory in spirit: an unmatched value falling silently through a case is a bug you find weeks later.

Real-world analogy — the mail sorter. A mailroom sorter with labeled pigeonholes: reads one envelope's label, walks the row, drops it in the first hole whose label pattern fits, done. Some holes accept several labels (prod|production), some accept families (dev*), and the last hole is labeled EVERYTHING ELSE — because mail with no home must land somewhere visible, not flutter to the floor.

Where the analogy stops working. A human sorter notices when two hole-labels overlap and asks which wins. case never notices: order is the only tiebreak — put dev* above devops-prod and the specific label never gets mail. The discipline: specific patterns first, families after, * last, and re-check ordering every time you add a branch.

🧪 Exercise 5.6 — route by shape
bash
cd ~/bash-course
nano route.sh     # the case block below, wrapped in a script:
bash
#!/bin/bash
env="$1"
case "$env" in
    prod|production)  echo "deploying carefully" ;;
    staging)          echo "deploying to staging" ;;
    dev*)             echo "anything goes" ;;
    *)                echo "unknown environment: $env" >&2 ; exit 2 ;;
esac
bash
chmod +x route.sh
./route.sh staging
./route.sh production
./route.sh dev-local
./route.sh qa ; echo "verdict: $?"

(One peek ahead: $1 means "the first word typed after the script's name" — Module 7 owns that syntax; today it is just the wire that gets your test values into the case.)

Expected result — click to reveal (the last run fails on purpose)
plain text
deploying to staging
deploying carefully
anything goes
unknown environment: qa
verdict: 2

What to read out of it: four callers, four routes. production rode the | alternative; dev-local matched the dev* family; and qa — the value nobody planned for — hit the catch-all, which did everything right at once: named the bad value, spoke on stderr, and exited 2 (usage-error convention, Module 3). That one catch-all line is the difference between "the deploy tool rejected qa" and "the deploy tool did nothing and nobody knows why."

D2. Choosing your conditional: the decision tree

Note: the diagram below is a Mermaid code block. Notion does not render it automatically — click the block and switch it from "Code" to "Preview" (or "Split") to see the flowchart.
Diagram source
flowchart TD
    A["I need to branch"] --> B{"On what?"}
    B -->|"a command's<br>success/failure"| C["if command; then<br>(no brackets at all)"]
    B -->|"a question about<br>files/strings/numbers"| D{"script dialect?"}
    B -->|"one value vs<br>many patterns"| E["case ... esac<br>specific first, * last"]
    D -->|"#!/bin/bash"| F["if [[ ... ]]<br>splitting-safe, patterns, =~"]
    D -->|"#!/bin/sh<br>portable"| G["if [ ... ]<br>quote every expansion"]
    C --> H{"just a guard<br>one-liner?"}
    H -->|"yes"| I["command || exit 1<br>(Module 3 style)"]
    H -->|"real else-branch"| J["full if/else/fi"]

Interview questions — Part D

🎯 "Explain the use of case statement in shell scripting" — asked at KnowledgeHut ("How do you use conditional statements (e.g., if, case)…") and listed at Hirist

The direct answer: case "$value" in pattern) commands ;; … esac routes one value through glob patterns, first match wins — the readable replacement for elif-ladders keyed on a single variable; standard for subcommand dispatch (start|stop|restart) and environment routing.

Going deeper: the left sides are patterns, not strings — | alternatives, * families — and order is the only precedence, so specific-before-general is a correctness rule, not style. A defensive *) branch that names the unmatched value on stderr and exits non-zero belongs in every production case.

The details that separate candidates: knowing the ;& and ;;& fallthrough variants exist in bash (;& falls through, ;;& keeps testing) but are rare and version-bound — mentioning them and advising against them reads as experience; and the pairing insight: case for routing, [[ for questions — the constructs complement rather than compete.

🎯 "Determine the output of the following command: [ -z \"\" ] && echo 0 || echo 1" — asked verbatim at Edureka

The direct answer: 0. -z "" asks "is the empty string empty?" — yes, exit 0 — so && fires and echoes 0; the || is skipped because echo succeeded.

Going deeper: the question is a two-module trap check — you must know both -z (this module) and the a && b || c mechanics (Module 3): had the echo somehow failed, 1 would print as well. Walking the verdicts aloud, operator by operator, is the answer format interviewers want.

The details that separate candidates: pointing out the cruel joke in the question's design — it prints 0 for true and 1 for false, deliberately inverting every other language's convention while exactly matching bash's exit-code convention (Module 3's counter-intuitive zero). Candidates who name that inversion, unprompted, demonstrate they hold both mental models at once.

Part E — Production

E1. 🏭 Production practices

These are the practices in production — how the things this module taught are actually done on real systems:

Guards first, work second. Scripts open with a block of preflight conditionals — required files (-f), directories (-d), permissions (-r/-w/-x), non-empty inputs (-s) — each failing fast with a named error on stderr and a meaningful exit code, before any real work begins.

[[ ]] in bash scripts; [ ] only in deliberate #!/bin/sh scripts — and quoted to the teeth there. Mixing the two styles in one file is treated as a review flag: it usually means copy-paste from mismatched sources.

Every expansion inside [ ] is quoted, and ShellCheck enforces it. SC2086 and friends turn the vanishing-argument trap into a CI failure instead of a 3 a.m. page.

Every case has a *) branch that names the value, speaks on stderr, and exits non-zero. Silent fallthrough is a defect class of its own.

String and number comparisons are never mixed. =/== for text, -eq family for integers; version strings go to sort -V or real tooling, never to <.

Complex conditions get a name. When a guard grows past two clauses, production code wraps it in a function with a speaking name (is_prod_host, Module 8) — the if-line then reads as policy, not puzzle.

E2. Production-practice table

SymptomWhat is really happeningWhat to runThe fix
[: =: unary operator expected in the logs; the if "sort of works anyway"An unquoted variable expanded to nothing and vanished from the test's arguments — exit 2 masquerading as falseprintf '[%s]\n' "$var" (Module 2's probe) on the suspect variableQuote every expansion inside [ ]; adopt [[ ]] in bash scripts; let ShellCheck catch strays
[-f: command not found (exit 127)Missing space — [ is a command and needs to stand alone as a wordLook at the failing line; the error names the mashed-together word[ -f file ] with spaces around both brackets
A version check waves through 10.x as "older than 9.x"String comparison — dictionary order sorts "10…" before "9…"[ "10.2" \< "9.9" ] && echo bug — watch it passCompare numbers with -lt per component, or sort -V (Module 11); never raw string order for versions
[[: not found on some hosts/containers onlyThe script says #!/bin/sh (or runs under sh) — dash has no [[ (Module 1 C2's dialect trap, bracket edition)head -1 script.sh and ls -l /bin/sh on the failing hostShebang #!/bin/bash if bash features are wanted, or rewrite tests portably with [ ]
A case branch never runs, no matter the inputAn earlier, broader pattern (dev* above dev-prod) is swallowing its mail — order is the only precedenceRead the patterns top to bottom against the failing value, first-match rulesReorder specific-before-general; add a test run per branch after any edit
Unknown value sails through a case silently; downstream breaks laterNo *) catch-all — the switchboard dropped the call on the floorFeed the case a nonsense value and watch nothing happenAdd *) echo "unexpected: $value" >&2 ; exit 2 ;; to every production case

E3. 🎓 Capstone — four tickets from the queue

Work each ticket yourself before opening the answer. Everything needed was taught in Modules 1–5.
🎓 Ticket 1 — "Our disk-space monitor pages when usage exceeds the threshold. Last night it paged claiming 9% > 85%. The check: if [ \"$usage\" \\> \"$threshold\" ]."

Diagnosis. String comparison doing string things (B2): with usage=9 and threshold=85, dictionary order compares first characters — 9 sorts after 8 — so "9" > "85" is TRUE. The monitor has also been silent about real overruns like 100 vs 85 ("100" < "85" as strings), which is the scarier half of the ticket.

Work the steps: reproduce both directions: [ "9" \> "85" ] && echo false-page and [ "100" \> "85" ] || echo missed-alert. Two one-liners, whole bug demonstrated.

Fix and prevention: numeric family: if [ "$usage" -gt "$threshold" ] — plus input validation upstream ([[ "$usage" =~ ^[0-9]+$ ]]) so a df hiccup that yields 9% or an empty string is rejected loudly (exit 2, message on stderr) instead of compared creatively. Review rule: any \> or \< inside [ ] near numbers is presumed a bug until proven a deliberate string sort.

🎓 Ticket 2 — "Deploy script: if [ $CONFIRM = \"yes\" ]; then deploy; fi. Works for everyone except CI, where it spams unary operator expected — but the deploy is skipped, so 'no harm done,' says the author."

Diagnosis. B3's vanishing argument: humans always export CONFIRM; CI doesn't, the unquoted empty expansion evaporates, [ receives [ = yes ], errors with exit 2 — which happens to skip the then-block, so the bug impersonates correct behavior while logging garbage. "No harm done" is luck, not design: flip the logic to != for a safety check and the same malformation would run the guarded action.

Work the steps: CONFIRM= bash -c '[ $CONFIRM = yes ]; echo $?' → error + 2; quoted variant → clean 1.

Fix and prevention: quote it — [ "$CONFIRM" = "yes" ] — or [[ $CONFIRM == yes ]] in this bash script; then make the malformed state impossible: : "${CONFIRM:?CONFIRM must be set}"-style required-variable checks (Module 9 owns that syntax) or set -u. Exit-2-with-stderr is never "fine" in a deploy path, even when today's routing is lucky.

🎓 Ticket 3 — "Service dispatcher: case $1 in start*) …;; stop) …;; status) …;; esac. Someone ran ./svc.sh startover and it started the service. Separately, ./svc.sh restart does nothing at all — no output, exit 0."

Diagnosis. Two switchboard sins in one case (D1). Sin one: the sloppy family pattern start* accepts startover, started, startanything — pattern generosity nobody intended. Sin two: no *) catch-all, so restart — never wired — fell on the floor silently with the last branch's happy exit code.

Work the steps: feed the case its own patterns: startover (matches branch 1 — why?), restart (matches nothing — then what runs? nothing). Both answers are visible from reading top to bottom with first-match rules.

Fix and prevention: exact labels for commands — start) … ;; stop) … ;; restart) … ;; status) … ;; — families like start* are for genuinely family-shaped data (hostnames, versions), not verbs; and the non-negotiable *) echo "usage: $0 {start|stop|restart|status}" >&2 ; exit 2 ;;. A dispatcher that cannot say "I don't know that command" is not dispatching; it is gambling.

🎓 Ticket 4 — "A hardening script must run only on staging: if [[ $HOSTNAME == *staging* ]]. It just ran on prod-staging-gateway — which is a production host — and hardened it into an outage."

Diagnosis. Pattern generosity again, at the [[ level (C1): *staging* matches any hostname containing staging, and someone named a prod box prod-staging-gateway. The conditional did exactly what the pattern said; the pattern said more than the author meant. (Naming is data — and data drifts; Module 2's fleet warning in a new costume.)

Work the steps: enumerate what the pattern accepts: for h in staging-1 prod-staging-gateway; do [[ $h == *staging* ]] && echo "$h matches"; done — both print (loop syntax is Module 6, arriving next; read it as "try each value").

Fix and prevention: anchor the intent — [[ $HOSTNAME == staging-* ]] if staging hosts follow staging-N, or better, stop deriving authorization from names: check an explicit marker ([ -f /etc/deploy-tier ] && [[ $(cat /etc/deploy-tier) == staging ]]) that provisioning writes deliberately. Naming conventions are documentation; markers are contracts. Destructive scripts get contracts.

E4. Documentation reference

TopicAuthoritative referenceWhat you'll find there
if, case, [[ ]]Bash manual — Conditional ConstructsExact syntax and semantics of all three constructs, including ;;/;&/;;&
Every test operatorBash manual — Bash Conditional ExpressionsThe complete catalogue: file, string, and arithmetic tests with precise definitions
test / [ as commandstest(1) — man7.org · Bash manual — Bourne Shell BuiltinsThe standalone command's rules — and confirmation that [ is just its other name
The patterns case and == useBash manual — Filename ExpansionThe glob language (*, ?, […]) shared by Module 2's globbing, case, and [[ == ]]

E5. Self-assessment

Answer from memory, out loud or on paper. Every answer is in this module.

  1. What does if actually evaluate? Why does if ./deploy.sh; then work with no brackets anywhere?
  2. What is [, literally? Name three consequences that follow from that fact.
  3. Why are the spaces in [ -f file ] mandatory, and what error appears without them?
  4. -e vs -f vs -d vs -s — one clause each, and one production use each.
  5. With name="", why does [ $name = "alice" ] exit 2 rather than 1 — and why is that distinction operationally important?
  6. = vs -eq: which family for which data, and what does [ "10" \< "9" ] teach about the difference?
  7. Name the four things [[ ]] does that [ ] cannot, and the one thing [ ] does that [[ ]] cannot.
  8. In [[ $host == web-* ]], why must web-* stay unquoted? What changes if you quote it?
  9. Write the standard numeric-input validator and explain both anchors.
  10. In a case statement, what determines which branch runs when several patterns could match? What ordering discipline follows?
  11. Why does every production case end with *), and what three things should that branch do?
  12. Walk through [ -z "" ] && echo 0 || echo 1 verdict by verdict. What prints, and why is the output's numbering a joke at your expense?

E6. Sources

Interview questions in this module were captured verbatim from:

Zero To Mastery — Bash Interview Prep: 31 Essential Questions and Answers — "How do you use conditional statements in Bash?", "How do you check for file or directory existence in Bash?" (published June 18, 2026)

Hirist — Top 30+ Shell Scripting Interview Questions and Answers — "What is the difference between == and -eq in shell scripting?", "How do you validate numeric input in shell scripts?" (published Jul 22, 2025; last modified Dec 31, 2025)

Edureka — Top 60 Shell Scripting Interview Questions and Answers — "How to check if a directory exists?", "Determine the output of the following command: [ -z "" ] && echo 0 || echo 1" (page updated Dec 9, 2024)

KnowledgeHut — Shell Scripting Interview Questions and Answers — "How do you use conditional statements (e.g., if, case) in a shell script?" (no publication date shown on page)

A note on the corpus: the [ vs [[ distinction — arguably the most interview-tested fact in this module — circulates in countless real screens and reference articles but rarely appears in published lists with a fixed wording; its summary line above says so rather than inventing a citation. No questions were invented. All expected-result outputs were produced by running the commands on Ubuntu 24.04 / bash 5.2.

Next: deciding once is a conditional; deciding for every file in a directory or every host in a list is a loop — including the famous trap of looping over files the wrong way: Module 6 — Loops.
Spotted a mistake or want something added? Send me a note.