Advanced case Pattern Matching in Bash
AI generated
$_
#!/
Bash · case · Pattern Matching · Control Flow
Advanced case Pattern Matching in Bash
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.

16 min read case · ;& · ;;& Bash 4.x · 5.x · POSIX

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.

11. FAQ: Advanced case Pattern Matching in Bash: The Essentials at a Glance

1How do I combine multiple patterns in one case branch?
With the pipe character | between the patterns, for example yes|y|Y). All listed patterns trigger the same code block, regardless of their order within the branch.
2What is the difference between ;& and ;;&?
;& unconditionally runs the code of the very next branch after the current one, without checking its pattern. ;;& keeps testing the following patterns normally and only runs branches that genuinely match.
3From which Bash version do ;& and ;;& work?
Both terminators were introduced in Bash 4.0. Older Bash versions or POSIX sh only offer the classic ;; terminator, with no fallthrough option.
4Can I use wildcards like * inside a case pattern?
Yes, every case pattern is a normal glob pattern with the same wildcards as file globbing: * for any sequence of characters, ? for one character, and [abc] for character classes.
5How do I make case comparisons case-insensitive?
With shopt -s nocasematch before the case block. The option applies globally for the session, so it is best to disable it again right after the affected block with shopt -u nocasematch.
6Do extglob operators like @(...) also work in case?
Yes, once shopt -s extglob is active, the same extended pattern operators like @(...), !(...), ?(...) also work as case patterns, which is especially useful for argument parsing.
7When is case worth it over an if/elif chain?
Once more than three or four branches check the same expression, case reads more clearly, because the expression appears only once in the header line instead of being repeated in every elif branch.
8Is case faster than a long if/elif chain?
case evaluates the tested expression only once, while an if/elif chain can re-evaluate it on every condition. For expensive expressions or command substitutions, that makes a measurable difference.
9What happens if no pattern matches in a case statement?
If no pattern matches and there is no catch-all * pattern, simply no code block runs and the statement ends without an error. Robust scripts should always include a final *) pattern.
10Is case suitable for several hundred possible values?
Technically yes, but for very many fixed, exact values, an associative array as a dispatch table with constant-time lookup is often the more maintainable and faster alternative to a very long case statement.