Advanced patterns, fallthrough, and case as a replacement for long if/elif chains
Many Bash scripts only use the case statement for a handful of fixed strings, but it can do far more: multiple patterns in one branch, wildcard combinations, deliberate fallthrough with ;& and ;;&, and case-insensitive matching. Anyone who knows these tools can replace long, hard-to-maintain if/elif chains with compact, readable branches.
Table of Contents
- 1. The basic structure of case and why it does more than three options
- 2. Multiple patterns per branch with the alternation operator |
- 3. Wildcard combinations: character classes, prefixes and suffixes
- 4. Unconditional fallthrough with ;& in Bash 4 and later
- 5. Conditional continued testing with ;;& since Bash 4
- 6. case for argument parsing: extglob patterns in practice
- 7. Case-insensitive matching with shopt -s nocasematch
- 8. Quoting pitfalls: when a pattern is literal and when it is a glob
- 9. case as an alternative to long if/elif chains
- 10. Summary
- 11. FAQ
1. The basic structure of case and why it does more than three options
A case statement compares a single expression against a list of patterns and runs the code block of the first matching pattern, closed with esac. The decisive difference from a chain of if/elif is that the expression under test is evaluated only once, while an if/elif chain can call the same expression or command again on every condition, which makes a real difference for expensive checks or command substitutions.
Many scripts use case exclusively for simple, fixed strings like start, stop, or restart, leaving the construct's real strength unused: every pattern is a full glob pattern with wildcards, character classes, alternation via |, and, once extglob is enabled, even extended pattern operators like @(...) and !(...).
#!/usr/bin/env bash
set -euo pipefail
read -r command
case "$command" in
start)
echo "Starting service"
;;
stop)
echo "Stopping service"
;;
*)
echo "Unknown command: $command" >&2
exit 1
;;
esac
2. Multiple patterns per branch with the alternation operator |
Within a single branch, several patterns can be combined with the pipe character |, so that any of the listed patterns triggers the same code block. This is especially useful for grouping several spellings or abbreviations of the same command, such as -h and --help, or covering different casing variants, without writing a separate, functionally identical branch for each one.
It matters that patterns within a |-separated branch are checked independently of each other, and the order within the list makes no difference as long as none of the patterns collides with an earlier branch. Since case always runs only the first matching branch, more specific patterns should generally come before more general wildcard patterns, otherwise the specific branch is never reached.
#!/usr/bin/env bash
set -euo pipefail
read -r flag
case "$flag" in
-h|--help|-\?)
echo "Showing help text"
;;
-v|--version)
echo "Showing version"
;;
yes|y|Y|YES)
echo "Confirmed"
;;
*)
echo "Unrecognized flag: $flag" >&2
exit 1
;;
esac
3. Wildcard combinations: character classes, prefixes and suffixes
Because every case pattern is a normal glob pattern, the same wildcards from file globbing apply: * for any sequence of characters, ? for exactly one character, and [abc] or [0-9] for character classes. A pattern like *.log matches any string ending in .log, while [0-9][0-9] requires exactly two consecutive digits, which works well for simple format validation, such as checking a two-digit month.
Combined wildcard patterns are especially helpful for classifying input values, for example distinguishing between different version formats, log-level prefixes, or filename schemes. A common use case is recognizing IP address ranges or version-number prefixes directly in the case pattern, without needing a separate regular expression via grep or [[ =~ ]].
#!/usr/bin/env bash
set -euo pipefail
read -r log_line
case "$log_line" in
ERROR:*|FATAL:*)
echo "Critical: $log_line"
;;
WARN:*)
echo "Warning: $log_line"
;;
[0-9][0-9][0-9]\ *)
echo "Numeric status code line: $log_line"
;;
*)
echo "Info: $log_line"
;;
esac
4. Unconditional fallthrough with ;& in Bash 4 and later
By default, case fully stops processing after the first matching branch, without ever looking at the following branches, similar to an implicit break in other languages. Starting with Bash 4, ;& is available as a terminator instead of the usual ;;, which unconditionally runs the code of the very next branch after the current one finishes, without ever checking that branch's pattern.
This behavior fits staged actions, where a higher stage should automatically include all actions from lower stages, for example a log-level system where debug should also show all info output. It matters to use ;& deliberately and sparingly, because it breaks the script's linear reading order, and a reader looking only at the current branch can easily miss the fallthrough behavior.
#!/usr/bin/env bash
set -euo pipefail
readonly LEVEL="${1:-info}"
case "$LEVEL" in
debug)
echo "[DEBUG] verbose diagnostic output"
;&
info)
echo "[INFO] general status messages"
;;
*)
echo "Unknown level: $LEVEL" >&2
exit 1
;;
esac
5. Conditional continued testing with ;;& since Bash 4
Besides ;&, Bash 4 also introduced ;;&, which differs subtly but importantly: instead of unconditionally running the next branch, Bash continues testing the following patterns normally after ;;& and only runs the code block of each branch that genuinely matches. This makes it possible to run several independent checks against the same value without re-evaluating that value across multiple separate case blocks.
A practical example is validating a filename against several independent criteria at once, such as file extension and naming convention, where each matching criterion prints its own message regardless of whether an earlier pattern already matched. This technique effectively replaces several consecutive if blocks that all check the same value with a single, clearly structured case statement.
#!/usr/bin/env bash
set -euo pipefail
readonly FILE="report_2026_final.csv"
case "$FILE" in
*.csv)
echo "Format: CSV"
;;&
*_final.*)
echo "Marked as final version"
;;&
report_*)
echo "Matches report naming convention"
;;
esac
# All three matching branches run, because ;;& keeps testing further patterns
6. case for argument parsing: extglob patterns in practice
Once shopt -s extglob is active, the same extended pattern operators from file globbing also work inside case branches. The @(...) operator can group several equivalent command-line options into a single pattern, while !(...) deliberately captures everything except a specific pattern, for example rejecting every argument that is not a known flag as invalid.
In a classic argument-parsing loop with while and shift, case typically takes on the role of checking each individual command-line argument against the list of allowed options. Combining case with extended patterns makes such parsers noticeably more compact than an equivalent if/elif chain full of individual string comparisons and regular expressions.
#!/usr/bin/env bash
set -euo pipefail
shopt -s extglob
while (( $# > 0 )); do
case "$1" in
--verbose|-v)
readonly VERBOSE=1
shift
;;
--output=*|-o=*)
readonly OUTPUT="${1#*=}"
shift
;;
!(-*))
readonly TARGET="$1"
shift
;;
*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
done
7. Case-insensitive matching with shopt -s nocasematch
By default, case strictly distinguishes between uppercase and lowercase, so a pattern yes does not match input Yes. With shopt -s nocasematch, this behavior changes globally for every subsequent case statement and also for [[ ]] pattern matches, ignoring case across all patterns until the option is disabled again with shopt -u nocasematch.
Because nocasematch applies globally for the whole shell session or script, it is best enabled immediately before the affected case block and disabled again right after, rather than setting it once at the top of the script. Otherwise, later comparisons in the script that were meant to be case-sensitive behave unexpectedly loose, leading to logic bugs that are hard to track down.
#!/usr/bin/env bash
set -euo pipefail
readonly ANSWER="Yes"
shopt -s nocasematch
case "$ANSWER" in
yes)
echo "Confirmed (case-insensitive match)"
;;
no)
echo "Declined"
;;
esac
shopt -u nocasematch
8. Quoting pitfalls: when a pattern is literal and when it is a glob
An often overlooked point is that unquoted characters in a case pattern are always interpreted as glob metacharacters, while quoted parts are treated literally. A pattern like *.txt matches any extension, but "*.txt" in quotes looks for the literal string *.txt, which is almost never the intended behavior. Anyone who deliberately wants to match special characters like *, ?, or [ literally has to escape them individually, for example with \*, rather than quoting the whole pattern.
A second pitfall involves variables inside a pattern: the value of an expanded variable, contrary to what you might expect, is still interpreted as a glob pattern as long as it appears unquoted in the pattern, so a variable value containing a * unintentionally becomes a wildcard. To guarantee a literal comparison against a variable, either quote the variable value explicitly or use an upfront test with [[ "$a" == "$b" ]], where both sides can be treated as plain strings.
9. case as an alternative to long if/elif chains
A long if/elif chain with ten or more branches quickly becomes hard to read, especially when every branch repeatedly checks the same expression, such as if [[ $x == a ]]; elif [[ $x == b ]]; elif [[ $x == c ]]. An equivalent case statement reads noticeably clearer, because the checked expression appears only once in the header line, and every branch shows only its own pattern, without a repeated comparison operator and repeated variable.
For very many possible values, for example more than twenty different commands in a dispatcher script, it is worth also considering associative arrays as a dispatch table, mapping function names directly to keys and looking them up in constant time, while both case and if/elif step through patterns sequentially in the worst case until one matches.
| Mechanism | Pattern type | Performance with many branches | Typical use |
|---|---|---|---|
case |
Glob pattern, expression evaluated once | Linear, but one expression | Command dispatch, argument parsing |
if/elif chain |
Arbitrary test expressions | Linear, each branch its own test | A few, complex conditions |
| Associative array dispatch | Exact key, no pattern | Constant time | Many fixed commands, function names |
select |
Numbered menu options | Linear, interactive | Interactive shell menus |
Mironsoft
Shell automation, DevOps tooling and deployment infrastructure
Shell scripts that hold up in production?
We review existing Bash scripts, spot fragile patterns and replace them with robust Bash patterns: complete error handling, logging and safe parallelization for your deployment stack.
Code Review
ShellCheck analysis and manual review for critical Bash pattern violations.
Refactoring
Retrofitting error handling, logging and safe file operations.
CI Integration
Wiring ShellCheck and BATS into pipelines and building regression tests.
10. Summary
Advanced case Pattern Matching in Bash: The Essentials at a Glance
Multiple patterns
The pipe character | combines several equivalent patterns in one branch, specific patterns belong before general wildcards.
Fallthrough
;& unconditionally runs the next branch, ;;& keeps testing the following patterns normally and only runs genuine matches.
Extended patterns
With extglob active, @(...), !(...) and other operators also work as case patterns, ideal for argument parsing.
When to prefer case
From roughly four or five branches checking the same expression onward, case reads clearer and performs better than an if/elif chain.