skip to content

A CI deploy script dispatches on its first argument with `case "$1" in start) do_start ;; stop) do_stop ;; esac` and then exits. Someone runs `./deploy statr`: nothing is printed, the exit status is 0, and the pipeline reports success. Why does bash behave that way, and how would you harden the dispatcher?

level: seniorimportance: should knowfreq 50%

answer

  1. no pattern matched means nothing ran
  2. unmatched case still returns zero
  3. silent success in a dispatcher
  4. always write the catch-all clause
  5. usage to stderr, exit non-zero

basics

~20 s

A bash case whose word matches no pattern runs nothing and returns exit status 0, so an unrecognised subcommand is indistinguishable from success. Harden it with a final catch-all clause that prints usage to stderr and exits non-zero.

solid answer

~50 s

`case` returns the exit status of the last command run in the clause that matched — and **zero when no pattern matched at all**, because nothing failed. So a typo'd subcommand silently does nothing and reports success, which is exactly what a CI job reads as a green deploy. The fix is a mandatory `*)` clause: print `unknown subcommand: $1` to stderr, show usage, and `exit 2`. I also split out an explicit empty-word clause so "no arguments" gets its own message, and I write the word as `"${1:-}"` so the case still evaluates when the script is called with no arguments under `set -u`. Note that strict mode does not save you here: nothing returned non-zero, so there is nothing for it to react to. The catch-all is the only thing that turns an unknown input into a failure.

code

bash · 13 lines
bash
#!/usr/bin/env bash
set -u

do_start() { echo "starting"; }
do_stop()  { echo "stopping"; }
usage()    { printf 'usage: %s {start|stop}\n' "$0" >&2; }

case "${1:-}" in
  start) do_start ;;
  stop)  do_stop  ;;
  '')    printf 'missing subcommand\n' >&2; usage; exit 2 ;;
  *)     printf 'unknown subcommand: %s\n' "$1" >&2; usage; exit 2 ;;
esac

go deeper

for a junior

Remember to end every case dispatcher with a *) clause that reports the bad input. Know that without it bash simply does nothing and the script still succeeds.

for a middle

State the two rules precisely: a matched case returns the last command's status, an unmatched case returns zero, and explain why that makes a missing default a silent failure.

for a senior

Diagnose the false green end to end — no failing command means error handling cannot help — and harden the script with a stderr message, a distinct exit code, and correct status propagation from the matched branch.

for a principal

Own the contract: define what exit codes your scripts use for usage errors versus work failures, and make a missing catch-all a review or lint rule rather than something each author remembers.

## What a case statement returns Two rules, and the second one is the whole bug: 1. If a pattern matches, the `case` returns the exit status of the **last command executed** in that clause's command list. 2. If **no** pattern matches, no commands run and the `case` returns **0**. Rule 2 is defensible — the compound command itself did not fail, it simply had nothing to do — but it means `case` is a silent no-op for unexpected input, and silence is the worst possible behaviour for a dispatcher. ## Why the pipeline went green Trace `./deploy statr`. The word `statr` is compared with `start`, then with `stop`. Neither matches. There is no other clause. Bash falls out of the `case`, the script reaches its end, and the script's exit status is the status of the last command it ran — the `case` — which is 0. The CI step sees 0 and reports success. Nothing was deployed, nothing was logged, and the failure only surfaces later when someone wonders why the new build is not live. It is worth being explicit about the thing that does *not* help: enabling strict error handling changes nothing here, because there is no non-zero status anywhere in the story. This is a class of bug that error-handling flags structurally cannot catch — the script did precisely what you wrote, and what you wrote was "if the input is unrecognised, succeed". ## The catch-all clause The `*)` pattern matches any word, so as the final clause it is the default branch. A hardened dispatcher: ```bash case "${1:-}" in start) do_start ;; stop) do_stop ;; '') printf 'missing subcommand\n' >&2; usage; exit 2 ;; *) printf 'unknown subcommand: %s\n' "$1" >&2; usage; exit 2 ;; esac ``` Four things are deliberate here. **The error text goes to stderr.** A usage error is diagnostic output; sending it to stdout pollutes anything that captures the script's output and hides the message when stdout is redirected to a log. **The exit status is non-zero.** Any non-zero value works; `2` is a common convention for "you called me wrong" as distinct from `1` for "the work failed". Pick one convention and use it across your scripts, because the caller can only act on the number. **Empty gets its own clause.** `./deploy` with no arguments is a different user mistake from `./deploy statr`, and a different message costs one line. The `''` pattern matches the empty word. **The word is written `"${1:-}"`.** With `set -u` in force, referring to `$1` when the script was called with no arguments aborts before the `case` is even evaluated, so the empty-argument branch would never be reached; supplying an empty default keeps the dispatcher in control of its own error reporting. ## The other half: propagating the branch's status Adding the catch-all fixes unknown input, but the matched path deserves the same scrutiny. Because a `case` returns the status of the last command in the matched clause, `start) do_start ;;` already propagates `do_start`'s failure — provided `do_start` is the last thing in the clause. Append a helpful `echo "done"` after it and you have overwritten the status with the `echo`'s 0, resurrecting the same false-green in a new place. Keep the call last, or capture and re-raise its status explicitly. ## The review rule Treat a `case` used as a dispatcher or a validator as incomplete until it has a `*)` clause. It is one of the cheapest review rules in shell: scan to `esac`, and if the last pattern is not `*`, ask what happens for input nobody thought of. The answer is always the same — nothing happens, successfully.

  • What exit status does a `case` return when a clause *does* match?
    The exit status of the last command executed in that clause's command list. So `start) do_start ;;` propagates whatever `do_start` returned, but adding a trailing `echo "done"` inside the clause overwrites it with zero. Keep the real work last in the clause, or capture its status and re-raise it deliberately.
  • Would turning on strict error handling have caught this deploy bug?
    No. Nothing in the script returned a non-zero status — the case matched nothing, ran nothing, and succeeded. Error-handling flags react to failures, and there was no failure to react to. Only an explicit catch-all clause converts unrecognised input into a failure; this is a design gap, not an error-handling gap.
  • How do you distinguish `./deploy` with no arguments from `./deploy statr`?
    Give the empty word its own clause — `'')` — with a distinct message, and write the case word as `"${1:-}"` so the script does not abort on an unset positional parameter before reaching it. Two clauses, two messages, both to stderr, both exiting non-zero.
  • Should the catch-all exit, or set a flag and continue?
    Exit, for a dispatcher. Continuing means the rest of the script runs with an input it has already declared invalid, and later steps then fail in confusing ways or, worse, succeed partially. Fail at the boundary, with the offending value in the message so the caller can see what was rejected.

saying these in an interview costs you the question

  • Thinks an unmatched case is a runtime error in bash
  • Assumes strict mode catches an unmatched dispatcher
  • Omits the catch-all because the input is always valid
  • Prints the usage message to stdout and exits zero
  • Believes case returns the status of the last clause tested

context