Making decisions based on many possibilities at once with `case ... esac`, matching multiple patterns in a single block, and validating inputs such as email, IP addresses, and numbers using regular expressions with the `=~` operator. Includes building a CLI command dispatcher in practice and the traps that commonly catch script writers.

In episode 9 we covered basic conditionals with if, elif, and else — how Bash makes decisions based on a command's exit status. In this episode we step up to the next level: matching one variable against many patterns at once with case, and testing strings using regular expressions with the =~ operator.
You might be wondering, "Isn't if already enough?" The answer: enough, but ugly. Imagine standing in front of a restaurant menu board that asks 50 questions in a row: "Do you want chicken?" No. "Do you want beef?" No. "Do you want fish?" No... A waste of time, right? case is a different way of thinking: open the menu, point directly at one choice. One variable, many patterns, one tidy block.
Regular expressions, on the other hand, are like a metal detector at the airport: not merely matching file names on a shelf (that's glob's job), but scanning the contents of a string to find character patterns wherever they appear. With =~ inside [[ ]], you can validate emails, IP addresses, or numbers in a few lines of code. Let's dissect both one at a time.
case?To feel the problem case solves, let's first look at the if version beginners usually write:
if [ "$ACTION" = "start" ]; then
echo "Menjalankan service..."
elif [ "$ACTION" = "stop" ]; then
echo "Menghentikan service..."
elif [ "$ACTION" = "restart" ]; then
echo "Me-restart service..."
elif [ "$ACTION" = "status" ]; then
echo "Memeriksa status service..."
else
echo "Aksi tidak dikenal."
fiThe code above works, but every elif line repeats the same pattern: [ "$ACTION" = "..." ]. The more choices there are, the longer the chain, and the greater the risk of a typo that leaves one branch never being called. This is where case shines: it separates the value being matched from the patterns being compared, and places everything in one structure that's easy for the eye to scan.
A rule of thumb you can take home: if you're writing more than two elifs comparing the same variable, that's a clear sign to switch to case.
case ... esacBash's case structure has four main parts: the case keyword, the variable being tested, one block per pattern ending in ;;, and the esac closer (that's case written backwards — an old Bash/Unix habit we also see with if...fi):
case "$VAR" in
pola1)
perintah_untuk_pola1
;;
pola2)
perintah_untuk_pola2
;;
*)
perintah_default
;;
esacLet's dissect each part:
case "$VAR" in — the variable being tested. Notice we wrap it in quotes. This matters: an empty variable or one containing spaces is still treated as a single string, not split into separate arguments or turned into a glob pattern.pola1) — the pattern to match. If $VAR matches this pattern, the command block below it executes.;; — the terminator. This tells Bash: "this pattern block is done, don't continue checking the next pattern." Forgetting to write ;; is one of the most common and most confusing mistakes, because the symptoms are often strange.*) — the catch-all. The * wildcard pattern matches anything, so this block acts as a default branch, equivalent to else in if.The analogy is simple: case is like a vending machine. You press one button ($VAR), the machine looks for that button among the choices, dispenses the matching product, and ;; is the click sound that signals "transaction done, don't check other buttons." If no button matches, the spare coin falls into the return tray (*).
Important
Every pattern block MUST end with ;; — except the last block before esac (though it's highly recommended to still write it for consistency). Without ;;, Bash doesn't know where a block ends, and commands from the previous block can bleed into the next pattern. When your case behaves strangely, check the ;; lines first — nine times out of ten, that's the culprit.
|Sometimes several different values must be treated the same way. Instead of writing two separate blocks, Bash lets you combine patterns with | (pipe, read as "or"). The values id and id_ID, for example, both mean Indonesian:
case "$LANG" in
id|id_ID)
echo "Halo, selamat datang!"
;;
en|en_US|en_GB)
echo "Hello, welcome!"
;;
*)
echo "Hi!"
;;
esacGrouped patterns are like one bus route serving several stops: passengers boarding at any stop still get on the same bus. The more equivalent aliases there are, the greater the benefit — without |, you'd have to write one ;; block for id, another for id_ID, and so on.
Now let's tie all the concepts above into the pattern most often seen in the real world: a command dispatcher. This is the pattern behind almost all init scripts, systemd helpers, and self-made CLI tools. You pass a command name as the first argument, and case translates it into an action:
#!/usr/bin/env bash
ACTION="${1:-help}"
case "$ACTION" in
start)
echo "Menjalankan service myapp..."
# misal: systemctl start myapp
;;
stop)
echo "Menghentikan service myapp..."
;;
restart)
echo "Me-restart service myapp..."
;;
status)
echo "Status service myapp:"
# misal: systemctl status myapp
;;
help|*)
echo "Penggunaan: $0 {start|stop|restart|status}"
exit 1
;;
esacNotice several details worth copying:
ACTION="${1:-help}" — if there's no argument, $1 is empty and becomes help. This prevents case from matching an empty string.help|* as the last pattern — the help pattern catches an explicit help request, and * catches everything else. By combining them, one block serves two functions at once.exit 1 — when the action is unknown, the script exits with a failure status, rather than silently continuing to the next line. In a pipeline or CI, this exit status is what determines success or failure.casecase doesn't only match exact strings — the patterns it uses are glob patterns, the same ones we covered for pathname expansion. You can use * (anything), ? (one character), and [...] (character ranges). These patterns are very useful for detecting file types by extension:
case "$FILE" in
*.tar.gz|*.tgz)
tar -xzf "$FILE"
;;
*.zip)
unzip "$FILE"
;;
*.png|*.jpg|*.jpeg|*.gif)
echo "$FILE adalah gambar"
;;
*)
echo "Tipe file tidak dikenal: $FILE"
;;
esacNotice the *.tar.gz pattern: the dot is written as-is (no need to escape it like in regex), because in a glob pattern a dot is an ordinary character. This is one of the key differences we'll discuss in a moment.
=~ Inside [[ ]]case is the right tool for matching values against global patterns. But what if you need to test the shape of a string's contents — for example, "does this string contain at least one digit", or "does this string look like an email address"? For that, Bash provides the =~ operator, which compares a string against a regular expression (regex). It's only available inside the compound test [[ ... ]].
Distinguish the two with this analogy:
case and *) is like sorting documents on a shelf by file name. The pattern matches the entire string from start to end.=~) is like scanning a document's contents with a detector. It searches for character patterns anywhere inside the string, and every symbol has a precise meaning: . = any character, + = one or more, {2,} = at least two, and so on.The basic syntax:
if [[ "$input" =~ ^[0-9]+$ ]]; then
echo "$input terdiri dari digit saja."
else
echo "$input mengandung karakter non-digit."
fiThe most classic case: validating whether a string looks like an email address before the script uses it. The regex below splits the email into three parts: the local name, @, and the domain:
email="deploy@example.com"
if [[ "$email" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]]; then
echo "Email valid: $email"
else
echo "Email tidak valid: $email"
fiLet's translate the pattern ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ piece by piece:
| Regex part | Meaning |
|---|---|
^ | Start from the beginning of the string |
[A-Za-z0-9._%+-]+ | One or more alphanumeric characters or local symbols |
@ | A literal @ character |
[A-Za-z0-9.-]+ | One or more characters in the hostname portion |
\. | A literal dot (escaped, because a plain . means "any character") |
[A-Za-z]{2,} | At least two letters (TLD like com, id, io) |
$ | End at the end of the string |
Tip
A full email validation regex is extremely complex — far more complex than the example above. For practical scripts, this kind of basic format validation is already enough to block most junk input. If you truly need strict validation, just send a verification email and let the mail server judge. Don't get trapped writing a 6,000-character regex.
The same pattern can be reworked to validate IPv4 addresses. The IPv4 format is four blocks of numbers 0–255 separated by dots:
ip="192.168.1.10"
if [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
echo "$ip berformat IPv4."
else
echo "$ip bukan alamat IPv4."
fiReading the pattern ^([0-9]{1,3}\.){3}[0-9]{1,3}$: the group ([0-9]{1,3}\.) means "1 to 3 digits followed by a literal dot", and {3} repeats that group three times, then it ends with 1 to 3 digits without a dot. Notice the escaped dot (\.) — this is the crucial difference from glob: in regex, an unescaped . matches any character, so 192,168,1,10 would also pass.
Warning
The regex above only validates format, not value ranges. 999.999.999.999 would pass even though it's not a valid IP (each block maxes out at 255). For full validation, add a range check: split the string with IFS='.' read -ra octet <<< "$ip" then check each octet between 0–255. Example: [[ ${octet[0]} -le 255 ]].
The simplest yet most often needed pattern: ensuring an input is a pure number before arithmetic operations. This prevents the mysterious integer expression expected error when $input turns out to contain random characters:
read -p "Masukkan umur: " umur
if [[ "$umur" =~ ^[0-9]+$ ]]; then
echo "Umur $umur tahun tercatat."
else
echo "Error: '$umur' bukan angka murni."
fiThere's a subtle trap that separates beginners from the experienced: what happens when the regex pattern is quoted on the right-hand side?
# BUKAN regex — string literal yang persis
if [[ "$input" =~ "^[0-9]+$" ]]; then
echo "String literal '^[0-9]+$' cocok"
fi
# Regex sungguhan — pola dievaluasi
if [[ "$input" =~ ^[0-9]+$ ]]; then
echo "$input terdiri dari digit"
fiThe rule is simple: if the right-hand side of =~ is quoted (or comes from a quoted variable), it's treated as a literal string — the characters ^, [, +, $ are considered ordinary characters, not regex meta-characters. This is why many people write:
pattern="^[0-9]+$"
if [[ "$input" =~ $pattern ]]; then
echo "Cocok!"
fiThe $pattern variable above is not quoted, so its value expands into an actual regex that gets evaluated. If you write =~ "$pattern", it becomes a literal. This behavior is asymmetric with the left-hand side — "$input" (the left side) may always be quoted, while the right side must be unquoted if you want it evaluated as a regex.
Here's a map of the traps people hit most often — save it as a checklist when your script behaves strangely:
| Mistake | Symptom | Solution |
|---|---|---|
Forgetting ;; in one block | Commands from the previous pattern also execute | Make sure every block ends with ;; |
Typo in esac (e.g. esca) | syntax error: unexpected end of file | Check the case closer — it must be esac |
case $VAR in without quotes | A variable containing globs/spaces matches unexpected patterns | Write case "$VAR" in |
Quoted regex on the =~ right-hand side | The pattern is treated as literal, never matches | Write =~ ^pola$ unquoted |
Unescaped . dot in regex | . matches any character | Write \. for a literal dot |
Using = instead of ==/=~ | Comparison always fails for =~ | Use =~ only inside [[ ]] |
Note
=~ is only available inside [[ ]] (Bash's built-in compound test). It doesn't work in [ ] (POSIX test) or outside a conditional context. If you get errors like [[: not found or =~: unary operator expected, that's a sign you're using the wrong brackets or the script is being run with sh instead of bash.
In this episode 10 we've added two important weapons to your conditional arsenal. With case ... esac you can match one variable against many patterns in a single tidy block — complete with pattern grouping (pattern1|pattern2), wildcards, and *) as the default branch — and we practiced building a command dispatcher, the backbone of many service scripts. Then, with =~ inside [[ ]], you can validate inputs like email, IPv4 addresses, and pure numbers using regular expressions, while understanding the classic trap: a quoted regex on the right-hand side turns into a literal string.
The principle to take home: if for binary decisions, case for many choices, and =~ for testing the shape of a string. The three together form the basis of dynamic decision-making — and decision-making without the ability to repeat is like cooking rice without a stove: possible, but incomplete.
In the next episode, episode 11, we'll open a new chapter: loops. We'll start with for — from the simple for item in a b c form, the {1..10} range, to the C-style for ((i=0; i<10; i++)), complete with break, continue, and a mass rename file practice. That's where you'll feel the full power of automation. See you in the next episode!