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.

From if-tree to dispatch table

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

  1. Identify same-variable comparisons. Wherever you see if [[ $X == "a" ]]; elif [[ $X == "b" ]]; elif …, you have a case candidate. The signal is the variable appearing on the left of every branch.

  2. Hoist the subject. Write case "$X" in as the first line — always quote the subject so word-splitting on whitespace doesn't bite.

  3. One pattern per row. For each branch write pattern) command ;;. Combine alternatives with |. Indent the body for readability.

  4. End with *). Make the catch-all explicit; either an error message and exit 1 or a deliberate default action. Never let an unexpected value silently fall through.

  5. Close with esac. That's case spelled 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.

Continue exploring

Tags