At the end of the previous lesson you wrote a function that walked four cities with a loop just to check whether a string was among them. It works, but it is the wrong tool: comparing a value against a closed list of alternatives is case's job. You already used it in passing in 03-05, inside the while [[ $# -gt 0 ]] that parsed long options, with the promise of explaining it fully later. That moment is now. Besides, case is half of a pair: the other half is select, the construct that generates numbered menus in three lines and with which we will build veloz-menu.sh, the toolkit's interactive front door.

Contents

  1. case syntax
  2. The patterns are globs, not regexes
  3. The *) wildcard and the evaluation order
  4. Terminators: ;;, ;& and ;;&
  5. case versus if/elif
  6. shopt -s nocasematch
  7. Idiomatic uses of case
  8. Long option parsing, fully explained
  9. Menus with select
  10. Menu best practices
  11. veloz-menu.sh and daily-report.sh with subcommands

  1. case syntax

case "$status" in
    delivered)   echo "Shipment completed"  ;;
    in_transit)  echo "On the road"         ;;
    issue)       echo "Needs attention"     ;;
    *)           echo "Unknown status: $status" >&2; return 1 ;;
esac

The pieces, one by one: case "$value" in introduces the word to be compared (quote it out of habit, even though case is one of the few contexts that does no word splitting); pattern) opens a branch, with the closing parenthesis mandatory and the opening one optional; the body accepts any number of commands, on one line or several; ;; closes the branch, and without it there is a syntax error; and esaccase backwards— closes the block.

Several alternatives in the same branch are separated with |, which reads as "or": y|Y|yes|YES|ok|OK) echo "Confirmed" ;;. If no pattern matches and there is no *), case does nothing and returns 0: absolute silence, and that is why *) is practically mandatory.

  1. The patterns are globs, not regexes

This is confusion number one. case patterns use the same rules as file globbing (02-05), although here they are applied to an arbitrary string.

Pattern Matches Does not match
Valencia exactly Valencia valencia
*.csv shipments.csv, a.b.csv csv, shipments.CSV
app.log.? app.log.1 app.log.12
[0-9][0-9] / [!0-9]* 08, 31 / a12 8, 123 / 2026
Val*|Sev* Valencia, Sevilla Bilbao

What does not work because it is regular expression syntax: +, {2,3}, \d, ^, $ and the groups (...). A pattern [0-9]+ in case literally means "a digit followed by a plus sign". POSIX classes are available ([[:digit:]], [[:alpha:]]), and to validate an identifier it is enough to write case "$id" in E[0-9][0-9][0-9][0-9]) ....

If you genuinely need a regular expression —"between two and four digits", "an email address"— the tool is [[ $s =~ regex ]], the topic of 05-04; with case you cover 90% of real cases and with far less complexity. One important detail: the compared value is not subject to globbing, but the pattern is expanded. That allows dynamic patterns: if p="Val*", the branch $p) matches Valencia. And if you want a pattern to be literal, quote it: "*") matches only an asterisk.

  1. The *) wildcard and the evaluation order

case evaluates the branches top to bottom and stops at the first one that matches. The order is therefore semantic: the specific first, the general afterwards.

case "$file" in
    app.log)      echo "Active log"            ;;
    app.log.*.gz) echo "Rotated and compressed" ;;
    app.log.*)    echo "Rotated"               ;;
    *.log)        echo "Another log"           ;;   # the general one, AFTER the specific ones
    *)            echo "Not a log"             ;;
esac

If *.log) were at the very top, it would swallow app.log) and the specific branches would never run. The final *) is the safety net: always cover it, even if only to log a warning. A case with no *) that receives an unexpected value does not fail, it simply does nothing, and that silence is the worst possible behavior in an operations script.

  1. Terminators: ;;, ;& and ;;&

Bash 4 added two more terminators to the classic ;;.

Terminator What it does after running the branch
;; Leaves the case. The usual one.
;& Runs the body of the next branch without checking its pattern (fall-through, like C's fallthrough)
;;& Keeps checking the remaining patterns and runs the ones that match
level="ERROR"
case "$level" in
    ERROR)      echo "→ send alert" ;;&   # ;;& → keeps checking patterns
    ERROR|WARN) echo "→ log it"     ;;&
    *)          echo "→ count it"   ;;
esac

Output: all three lines, → send alert, → log it and → count it. With ;; in every branch only the first line would have been printed. ;;& is useful for cumulative actions per category; ;& is rare and confuses the reader. Recommendation: use ;; by default and ;;& only when the accumulation is the explicit goal, with a comment saying so.

  1. case versus if/elif

Criterion case if/elif
What it compares One single variable against patterns Any condition, different in each branch
Kind of test Glob pattern match Exit codes, numbers, files
Alternatives per branch Yes, with | Requires chained ||
Readability with 5+ branches Excellent Degrades fast
Numeric ranges and combining and/or Clumsy or impossible Natural with (( ))

Rule: if all branches ask about the same value using equality or a pattern, use case; if each branch asks about something different or there are numeric comparisons, use if/elif. An if [[ "$sub" == "summary" ]]; then ... elif [[ "$sub" == "detail" ]]; then ... repeats the variable in every branch; the equivalent case names it once and lines the patterns up in a column, which makes it obvious at a glance which values are covered.

  1. shopt -s nocasematch

By default the comparison is case-sensitive. The nocasematch option turns that off, and it also affects [[ == ]]:

shopt -s nocasematch followed by case "$1" in valencia) ... makes VALENCIA, Valencia and vAlEnCiA all match, and shopt -u nocasematch turns it off. It is a global shell option: while it is active, any later comparison stops distinguishing case, including those in functions that were not expecting that behavior. That is why the explicit alternative from 04-04 is preferable, case "${1,,}" in valencia) ...: shorter, with no global side effects and obvious when you read it.

  1. Idiomatic uses of case

a) Validating against a closed list. The function from the 04-04 exercise, now on one line: is_valid_city() { case "${1,,}" in valencia|sevilla|bilbao|madrid) return 0 ;; *) return 1 ;; esac; }.

b) Yes/no confirmation from a read (which you already know from 03-05):

read -r -p "Purge logs older than 30 days? [y/N] " answer
case "${answer,,}" in
    y|yes) purge_logs ;;
    *) echo "Operation cancelled" ;;   # the safe option is always the default one
esac

c) Branching by file extension, taking advantage of the patterns being globs:

process() {
    case "$1" in
        *.tar.gz)  tar -tzf "$1"          ;;   # it must come BEFORE *.gz!
        *.gz)      zcat "$1" | tail -n 20 ;;
        *.csv)     column -t -s, "$1"     ;;
        *.log)     tail -n 20 "$1"        ;;
        *)         echo "Unsupported type: $1" >&2; return 1 ;;
    esac
}     # *.tar.gz is more specific than *.gz; placed afterwards, it would never be reached

d) Subcommand dispatch. The most powerful use, with which a single script offers several operations (just like git commit, git push):

sub="${1:-summary}"; shift || true      # the shift consumes the subcommand...
case "$sub" in
    summary)  cmd_summary "$@" ;;       # ...so that "$@" carries only ITS arguments
    detail)   cmd_detail  "$@" ;;
    cities)   cmd_cities  "$@" ;;
    help|-h|--help) usage; exit 0 ;;
    *) echo "Unknown subcommand: $sub" >&2; usage; exit 2 ;;
esac

The || true avoids failing if there was no argument to consume. You develop it fully in section 11.

  1. Long option parsing, fully explained

We pick up the block from 03-05 and now every line makes sense:

while [[ $# -gt 0 ]]; do
    case "$1" in
        --date)       report_date="${2:?--date requires a value}"; shift 2 ;;
        --date=*)     report_date="${1#*=}";                       shift   ;;
        --city)       city="${2:?--city requires a value}";        shift 2 ;;
        --city=*)     city="${1#*=}";                              shift   ;;
        -v|--verbose) verbose=1; shift ;;
        -h|--help)    usage; exit 0    ;;
        --)           shift; break     ;;
        -*)           echo "Unknown option: $1" >&2; exit 2 ;;
        *)            positional+=("$1"); shift ;;
    esac
done
set -- "${positional[@]}"

Now every piece has a name: the branches --date) and --date=*) accept both ways of writing an option, and the second one uses ${1#*=} from 04-04 to keep whatever comes after the =; shift 2 discards option and value while shift discards only the option; -- is the conventional marker for "options end here", so whatever comes after are arguments even if they start with a dash; -*) catches any unrecognized option and fails loudly instead of treating it as an argument; and *) accumulates the positional arguments in an array (04-03) that set -- "${positional[@]}" returns to $1, $2… now free of options. It is the complete manual parsing pattern, and you can now write it from memory.

  1. Menus with select

select is a specialized for: it prints a numbered list, shows a prompt, reads a number, runs the body with the variable set to the chosen element and repeats in a loop.

PS3="Choose an operation (number): "
select option in "Today's report" "View errors" "Quit"; do
    case "$option" in
        "Today's report") daily-report.sh ;;
        "View errors")    tail -n 20 /var/log/veloz/app.log ;;
        "Quit")           echo "See you later"; break ;;
        *)                echo "Invalid option: $REPLY" >&2 ;;
    esac
done

On screen you get the numbered list (1) Today's report…) and then the prompt. What you need to know:

  • PS3 is the variable holding the prompt. If you do not set it, you get the unfriendly #?.
  • option receives the text of the chosen element; REPLY holds what the user typed literally. If they type something that is not a valid number, option ends up empty but REPLY keeps the input: that is why the case's *) reports using $REPLY.
  • The loop is implicit and infinite. You only get out with break, with Ctrl+D or with exit. Without a "Quit" branch containing break, the menu never ends. Pressing Enter on a blank line reprints the list.
  • The list can come from an array: select c in "${CITIES[@]}" Quit; do ... done.

  1. Menu best practices

Always validate with a *) that catches out-of-range input; never assume the user types a correct number. Include an explicit exit option, because relying on Ctrl+C mistreats the user and leaves processes half-done. And above all: menus are the secondary interface, never the primary one, since a script that only works interactively cannot be put in cron (07-01) or chained with others. The rule is that the default behavior must be non-interactive, with options and subcommands, and that the menu is offered only when there are no arguments and the input is a terminal. The idiom is if [[ $# -eq 0 && -t 0 ]]; then main_menu; else dispatch "$@"; fi. The check [[ -t 0 ]] (03-05) asks whether standard input is a terminal; from cron it is not, so the script will never sit waiting for an answer that will not come. This pattern is the basis of what you will automate in 07-02.

  1. veloz-menu.sh and daily-report.sh with subcommands

First, the toolkit menu, in ~/veloz-ops/bin/veloz-menu.sh:

#!/usr/bin/env bash
# veloz-menu.sh — Interactive operations menu for Veloz Envíos.
set -u
readonly APP_LOG="/var/log/veloz/app.log"
main_menu() {
    local PS3="veloz-ops> " option
    select option in "Daily report" "Latest errors" "API status" Quit; do
        case "$option" in
            "Daily report")  daily-report.sh summary ;;
            "Latest errors") tail -n 20 "$APP_LOG" | grep --color=auto ERROR ;;
            "API status")    systemctl is-active veloz-api ;;
            Quit)            break ;;
            *) printf 'Invalid option: %s\n' "$REPLY" >&2 ;;
        esac
        echo
    done
}
if [[ $# -eq 0 && -t 0 ]]; then main_menu; else echo "Usage: $0" >&2; exit 2; fi

And daily-report.sh's dispatcher, which replaces the linear main from 04-02:

usage() { cat <<'END'
Usage: daily-report.sh <subcommand> [options]
  summary     Table of totals by city (default)
  detail      One line per shipment with an issue
  cities      Just the list of cities with activity
END
}

main() {
    local sub="${1:-summary}"; [[ $# -gt 0 ]] && shift
    validate_env || exit $?
    case "$sub" in
        summary)   accumulate_day "$(date +%F)" && cities_table ;;
        detail)    list_issues "$@"                            ;;
        cities)    printf '%s\n' "${CITIES[@]}"                ;;
        help|-h|--help) usage; exit 0                          ;;
        *) printf 'Unknown subcommand: %s\n' "$sub" >&2; usage >&2; exit 2 ;;
    esac
}
main "$@"

A single script thus covers three different operations, each with its own option parsing, and the menu wraps it for whoever prefers pressing numbers. The block cat <<'END' ... END is a here-document, a convenient way to write multi-line text that you will see in 05-05.

Common Mistakes and Tips

  • Forgetting ;; at the end of a branch: it is a syntax error, and Bash's message points at the wrong line. Remember too that the block is closed with esac, case backwards, just like if/fi.
  • Putting the general pattern before the specific one. *.gz before *.tar.gz leaves the second branch dead.
  • Using regex syntax in the patterns. +, {n} and \d mean nothing in a glob.
  • Omitting *). An unexpected value will make the case do absolutely nothing, silently.
  • A menu with no break. select is an infinite loop; without an exit option the user is trapped.
  • Leaving nocasematch enabled. It is global; turn it off or better use ${var,,}.
  • Tip: when a branch body goes past three lines, extract it into a function (cmd_summary). A dispatch case should read like a table of contents, not like the whole program.

Exercises

Exercise 1. Write classify_status() that takes a shipment status and prints OK, PENDING or REVIEW depending on whether it is delivered, in_transit or issue, accepting any combination of case and returning code 1 for an unknown status.

Exercise 2. Write a case that, given a file name, prints how it should be read: zcat for .gz, tar -tzf for .tar.gz, column -t -s, for .csv and less for everything else. Check the order of the branches.

Exercise 3. Build a menu with select offering the four cities plus an "All" option and a "Quit" one; choosing a city must print how many shipments it has, and "All" must walk through them.

Solutions

Solution 1.

classify_status() {
    case "${1,,}" in
        delivered)   echo "OK"      ;;
        in_transit)  echo "PENDING" ;;
        issue)       echo "REVIEW"  ;;
        *) printf 'Unknown status: %s\n' "$1" >&2; return 1 ;;
    esac
}

${1,,} normalizes without touching nocasematch, so the function does not alter the shell's global behavior.

Solution 2.

case "$file" in
    *.tar.gz|*.tgz) echo "tar -tzf $file"      ;;   # first, the most specific
    *.gz)           echo "zcat $file"          ;;
    *.csv)          echo "column -t -s, $file" ;;
    *)              echo "less $file"          ;;
esac

If *.gz) came first, shipments.tar.gz would fall into it and the tar would never be detected.

Solution 3.

readonly CITIES=(Valencia Sevilla Bilbao Madrid) CSV="/srv/veloz/data/shipments.csv"
count_city() { printf '%-10s %4d shipments\n' "$1" "$(grep -c ",$1," "$CSV")"; }

PS3="City (number): "
select c in "${CITIES[@]}" All Quit; do
    case "$c" in
        Quit) break ;;
        All)  for x in "${CITIES[@]}"; do count_city "$x"; done ;;
        "")   echo "Invalid input: $REPLY" >&2 ;;   # out-of-range number or text
        *)    count_city "$c" ;;
    esac
done

The "") branch is essential: when the input does not correspond to any element, select leaves the variable empty and REPLY holding whatever was typed.

Conclusion

case is the correct way to compare a value against a closed set of alternatives: syntax case value in pattern) body ;; esac, patterns that are globs (with | for alternatives and *) as a safety net), top-to-bottom evaluation stopping at the first match, and the terminators ;;, ;& and ;;& for the cases where you want to keep going. You will use it mostly for three things: validating input against closed lists, parsing options —the 03-05 block has no secrets left— and dispatching subcommands, which is what has turned daily-report.sh into a tool with summary, detail and cities. select completes the picture with numbered menus in three lines, always as a secondary interface and never as the only front door.

One single piece of the module is left, and it is the one we have been postponing from the start: numbers. Our report prints 128 shipments, 11 issues but is incapable of saying that this is 8.59%, because we have been truncating cents with ${amount%.*} and adding only integers. In 04-06 you will see why Bash does integer arithmetic only, how (( )) and $(( )) work, why an 08 can blow up a date script, and how to get real decimals with bc to close the report with percentages, averages and variation against the previous day.

Bash Programming Course

Module 1: Introduction to Bash

Module 2: Basic Bash Commands

Module 3: Scripting Fundamentals

Module 4: Intermediate Scripting

Module 5: Advanced Scripting Techniques

Module 6: Working with External Tools

Module 7: Automation and Scheduling

Module 8: Best Practices and Optimization

Module 9: Real-World Projects

© Copyright 2026. All rights reserved