Flow Control: Branching With Case
3 min read
Core idea
case is bash's multiple-choice compound command. It takes a single value, matches it against a list of glob-style patterns, and runs the first block whose pattern wins. Where a stack of if … elif … elif … else repeats the subject on every line, case says the subject once and lets each branch declare only its pattern. The result is a readable dispatch table — one column of patterns, one column of actions.
Why it matters
Repeated comparisons against the same variable are one of the most common shapes in shell scripts: menu handling, option parsing, file-extension routing, response classification. if-chains express that shape badly because the variable is restated in every test, which invites typos and obscures the dispatch. case collapses the chain into the structure it actually has — a value flowing into one of N labelled outcomes. It is also faster: bash matches patterns natively without spawning a subshell for each [[ … ]] test.
Mental model
From if-tree to dispatch table
An if-chain branches sequentially: each predicate evaluates against the variable, and control descends until one is true. case reorganises the same logic into a flat table — the subject is hoisted to the top, and each branch is just a pattern) action ;; row. Conceptually it's the difference between a chain of doors and a switchboard.
Pattern vocabulary
The patterns in a case arm aren't regular expressions — they're the same glob syntax the shell uses for filenames. That distinction matters when you read code, because a stray . is a literal dot, not "any character." Common patterns:
a— exact match.*.txt— anything ending in.txt.???— exactly three characters.[[:alpha:]]— a single alphabetic character.y|Y|yes— alternation; any of these.*— the universal catch-all; conventionally the last arm.
Fall-through with ;;&
By default case stops at the first match — a feature, not a limitation. But sometimes a single value really does belong to multiple categories (a is both alphabetic and a hex digit). bash 4.0+ added the ;;& terminator: after a match, keep testing the remaining arms. Use it sparingly; the default behaviour is what you want most of the time.
Practical application
-
Identify same-variable comparisons. Wherever you see
if [[ $X == "a" ]]; elif [[ $X == "b" ]]; elif …, you have acasecandidate. The signal is the variable appearing on the left of every branch. -
Hoist the subject. Write
case "$X" inas the first line — always quote the subject so word-splitting on whitespace doesn't bite. -
One pattern per row. For each branch write
pattern) command ;;. Combine alternatives with|. Indent the body for readability. -
End with
*). Make the catch-all explicit; either an error message andexit 1or a deliberate default action. Never let an unexpected value silently fall through. -
Close with
esac. That'scasespelled backwards — bash's signature closer for compound commands (if … fi,case … esac).
A minimal skeleton:
case "$REPLY" in
1) echo "one" ;;
2|two) echo "two" ;;
[3-5]) echo "small" ;;
*.txt) echo "text file: $REPLY" ;;
*) echo "unknown: $REPLY" ; exit 1 ;;
esac
Example
Imagine a tiny CLI that classifies an HTTP status code into a category for a structured log line. With if-chains it's a wall of repeated comparisons; with case and a numeric range pattern it's three lines plus a catch-all:
#!/usr/bin/env bash
# classify-status STATUS
status=$1
case "$status" in
2[0-9][0-9]) category="success" ;;
3[0-9][0-9]) category="redirect" ;;
4[0-9][0-9]) category="client_error";;
5[0-9][0-9]) category="server_error";;
*) category="unknown" ;;
esac
printf '{"status":%s,"category":"%s"}\n' "$status" "$category"
The pattern 2[0-9][0-9] does in one expression what a chain of arithmetic comparisons would do in four lines — and you can read the dispatch top to bottom like a routing table.
Related lessons
Related concepts
- Pattern Matchinglinked concept
- Control Flowlinked concept