opscape

Bash scripting guide · part 14 of 32

The case statement

Matching one value against glob patterns with case, alternatives with |, the default branch, fall-through with ;& and ;;&, and parsing options with case.

case compares one word against a list of patterns and runs the commands of the first pattern that matches. It replaces long if/elif chains that test the same variable, and its patterns are globs, the same *, ? and [...] you use for filenames. It is POSIX, so it works in sh as well as bash, which makes it the portable answer to many [[ == pattern ]] tests.

case WORD in
  PATTERN1) commands ;;
  PATTERN2 | PATTERN3) commands ;;
  *) default commands ;;
esac

Each branch ends with ;;. The word after case is not split or globbed, so it needs no quotes; most people quote it anyway.

A first example

Pick an action by file extension

for f in README.md data/config.json src/app.py src/main.c photos/beach.png src/Makefile; do
  case $f in
    *.md)           kind="markdown" ;;
    *.json | *.yaml) kind="config" ;;
    *.py | *.c | *.sh) kind="source code" ;;
    *.png | *.jpg)  kind="image" ;;
    *)              kind="unknown" ;;
  esac
  printf '%-18s %s\n' "$f" "$kind"
done
output
README.md          markdown
data/config.json   config
src/app.py         source code
src/main.c         source code
photos/beach.png   image
src/Makefile       unknown

| separates alternative patterns for one branch. * matches anything, so as the last pattern it is the default branch; src/Makefile fell through to it. Without a *), a value that matches nothing just skips the whole case.

Order matters

Patterns are tried top to bottom, and only the first match runs. Put specific patterns before general ones, or the general one swallows everything.

A general pattern before a specific one

f=backup.tar.gz
case $f in
  *.gz)     echo "wrong: treated as plain gzip" ;;
  *.tar.gz) echo "tarball" ;;
esac
case $f in
  *.tar.gz) echo "right: tarball" ;;
  *.gz)     echo "plain gzip" ;;
esac
output
wrong: treated as plain gzip
right: tarball

In the first case, *.gz matched before *.tar.gz got a chance. Reordering fixes it.

Character classes and yes/no answers

Bracket expressions match one character from a set, which makes case-insensitive answers easy without changing any shell option. POSIX classes work inside brackets too, as in [0-9[:upper:]].

Accept yes and no in any case

for answer in y YES Yes no N maybe ""; do
  case $answer in
    [Yy] | [Yy][Ee][Ss]) result=yes ;;
    [Nn] | [Nn][Oo])     result=no ;;
    "")                  result="empty, using default" ;;
    *)                   result="not understood" ;;
  esac
  echo "'$answer' -> $result"
done
output
'y' -> yes
'YES' -> yes
'Yes' -> yes
'no' -> no
'N' -> no
'maybe' -> not understood
'' -> empty, using default

[Yy][Ee][Ss] matches yes in any capitalization. "" is a pattern that matches only the empty string. In bash you could also lowercase first with ${answer,,} and match plain y|yes (Parameter expansion).

Classify input with character classes

for v in 42 -7 3.14 web01 "" "two words"; do
  case $v in
    "")                   t="empty" ;;
    *[!0-9-]* | ?*-*)     t="not an integer" ;;
    -*[0-9]* | [0-9]*)    t="integer" ;;
    *)                    t="not an integer" ;;
  esac
  printf '%-10s %s\n' "'$v'" "$t"
done
output
'42'       integer
'-7'       integer
'3.14'     not an integer
'web01'    not an integer
''         empty
'two words' not an integer

*[!0-9-]* matches any value containing a character other than a digit or -, and ?*-* catches a minus that is not at the start. What remains, with at least one digit, is an integer. Glob classes are less expressive than regular expressions; for anything more complex use [[ $v =~ ^-?[0-9]+$ ]].

Quoting inside patterns

Unquoted characters in a pattern are glob syntax; quoted ones are literal. A variable used as a pattern acts as a glob when unquoted, exactly as on the right side of [[ == ]].

Literal versus glob patterns

for v in '*' 'anything' '?'; do
  case $v in
    "*")  echo "'$v': the literal asterisk" ;;
    \?)   echo "'$v': the literal question mark" ;;
    *)    echo "'$v': something else" ;;
  esac
done
ext=log
case app.log in *."$ext") echo "matched the extension held in \$ext" ;; esac
output
'*': the literal asterisk
'anything': something else
'?': the literal question mark
matched the extension held in $ext

"*" and \? match only those characters. Combining a glob with a quoted variable, *."$ext", matches any name ending in .log while treating the variable's contents literally.

Fall-through: ;& and ;;&

Bash adds two terminators besides ;;. ;& falls through and runs the next branch's commands without testing its pattern, like a C switch without break. ;;& continues testing the remaining patterns and runs every other branch that also matches.

Run several matching branches

for host in web01 db01 build01; do
  tags=()
  case $host in
    web*|db*) tags+=(production) ;;&
    web*)     tags+=(public) ;;&
    db*)      tags+=(backup) ;;&
    *)        tags+=(monitored) ;;
  esac
  echo "$host: ${tags[*]}"
done
level=2
case $level in
  3) echo "verbose tracing" ;&
  2) echo "debug logs" ;&
  1) echo "info logs" ;;
esac
output
web01: production public monitored
db01: production backup monitored
build01: monitored
debug logs
info logs

With ;;&, web01 collected tags from every branch that matched, ending with the default. With ;&, level 2 ran its own branch and then branch 1 without testing it, so higher levels include everything below them.

Parsing command-line options

The most common real use of case is the loop that walks through a script's arguments. Each option is a branch; shift moves to the next argument. This handles long options, which the getopts builtin cannot (Script arguments).

An argument parsing loop

deploy.sh
#!/usr/bin/env bash
verbose=0 env=staging files=()
while (( $# > 0 )); do
  case $1 in
    -v | --verbose) verbose=1 ;;
    -e | --env)     env=$2; shift ;;
    --env=*)        env=${1#--env=} ;;
    -h | --help)    echo "usage: deploy.sh [-v] [-e ENV] FILE..."; exit 0 ;;
    --)             shift; files+=("$@"); break ;;
    -*)             echo "deploy.sh: unknown option: $1" >&2; exit 2 ;;
    *)              files+=("$1") ;;
  esac
  shift
done
echo "env=$env verbose=$verbose files=${files[*]}"
bash deploy.sh -v --env=prod app.tar.gz
bash deploy.sh -e dev -- -weird-name.txt
bash deploy.sh --force
outputexit 2
env=prod verbose=1 files=app.tar.gz
env=dev verbose=0 files=-weird-name.txt
deploy.sh: unknown option: --force

--env=prod was split with a parameter expansion, -e dev consumed the next argument with an extra shift, and -- ended option parsing so a filename starting with - got through. An unknown option exits with status 2, the usual code for usage errors.

Extended patterns

With shopt -s extglob, bash patterns gain @(a|b) (one of), !(pattern) (anything except), +(...) (one or more), *(...) (zero or more) and ?(...) (zero or one), which work in case too. The option must be on before bash parses the case, so set it near the top of the script, not on the same line. (Inside [[ ]], bash enables these patterns automatically.) See Globbing for the full syntax.

Extended patterns in case

shopt -s extglob
for v in 42 -7 3.14 007 web01 deploy; do
  case $v in
    ?(-)+([0-9])) echo "$v: integer" ;;
    !(*[0-9]*))   echo "$v: no digits at all" ;;
    *)            echo "$v: something else" ;;
  esac
done
output
42: integer
-7: integer
3.14: something else
007: integer
web01: something else
deploy: no digits at all

?(-)+([0-9]) reads as "an optional minus, then one or more digits", which replaces the two-pattern trick from the character class example. !(*[0-9]*) matches values with no digit anywhere: deploy qualifies, while web01 and 3.14 fall through to the default.

Related