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
casesyntax- The patterns are globs, not regexes
- The
*)wildcard and the evaluation order - Terminators:
;;,;∧;& caseversusif/elifshopt -s nocasematch- Idiomatic uses of
case - Long option parsing, fully explained
- Menus with
select - Menu best practices
veloz-menu.shanddaily-report.shwith subcommands
case syntax
case syntaxcase "$status" in
delivered) echo "Shipment completed" ;;
in_transit) echo "On the road" ;;
issue) echo "Needs attention" ;;
*) echo "Unknown status: $status" >&2; return 1 ;;
esacThe 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 esac —case 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.
- 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.
- The
*) wildcard and the evaluation order
*) wildcard and the evaluation ordercase 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" ;;
esacIf *.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.
- Terminators:
;;, ;& and ;;&
;;, ;& 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" ;;
esacOutput: 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.
case versus if/elif
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.
shopt -s nocasematch
shopt -s nocasematchBy 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.
- Idiomatic uses of
case
casea) 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
esacc) 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 reachedd) 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 ;;
esacThe || true avoids failing if there was no argument to consume. You develop it fully in section 11.
- 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.
- Menus with
select
selectselect 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
doneOn screen you get the numbered list (1) Today's report…) and then the prompt. What you need to know:
PS3is the variable holding the prompt. If you do not set it, you get the unfriendly#?.optionreceives the text of the chosen element;REPLYholds what the user typed literally. If they type something that is not a valid number,optionends up empty butREPLYkeeps the input: that is why thecase's*)reports using$REPLY.- The loop is implicit and infinite. You only get out with
break, withCtrl+Dor withexit. Without a "Quit" branch containingbreak, 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.
- 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.
veloz-menu.sh and daily-report.sh with subcommands
veloz-menu.sh and daily-report.sh with subcommandsFirst, 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; fiAnd 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 withesac,casebackwards, just likeif/fi. - Putting the general pattern before the specific one.
*.gzbefore*.tar.gzleaves the second branch dead. - Using regex syntax in the patterns.
+,{n}and\dmean nothing in a glob. - Omitting
*). An unexpected value will make thecasedo absolutely nothing, silently. - A menu with no
break.selectis an infinite loop; without an exit option the user is trapped. - Leaving
nocasematchenabled. 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 dispatchcaseshould 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" ;;
esacIf *.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
doneThe "") 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
- What Is Bash?
- Setting Up Your Environment
- Basic Command Line Navigation
- Understanding the Shell
- Finding Help: man, help and --help
Module 2: Basic Bash Commands
- File and Directory Operations
- Text Processing Commands
- File Permissions and Ownership
- Redirection and Piping
- Wildcards and Path Expansion
- History and Keyboard Shortcuts
Module 3: Scripting Fundamentals
- Creating and Running a Script
- Variables and Constants
- Basic Operators
- Conditional Statements
- Arguments and User Input
- Quoting, Expansion and Substitution
Module 4: Intermediate Scripting
- Loops in Bash
- Functions in Bash
- Arrays and Associative Arrays
- String Manipulation
- The case Statement and Interactive Menus
- Arithmetic and Numeric Calculations
Module 5: Advanced Scripting Techniques
- Advanced File Operations
- Process Management
- Error Handling and Debugging
- Regular Expressions
- Advanced I/O: Descriptors and Here-Documents
- Modular Scripts and Reusable Libraries
Module 6: Working with External Tools
Module 7: Automation and Scheduling
- Cron Jobs
- Automating Tasks
- Backup and Restore Scripts
- Monitoring and Logging
- Services and Timers with systemd
- Remote Automation with SSH
Module 8: Best Practices and Optimization
- Writing Readable Code
- Optimizing Bash Scripts
- Security Considerations
- Version Control with Git
- Static Analysis with ShellCheck and shfmt
- Automated Testing with Bats
- Portability: POSIX sh versus Bashisms
