Learn BASH Scripting - Advanced Conditionals (Case Statement & Regex)
Episode 10 of 27

Learn BASH Scripting - Advanced Conditionals (Case Statement & Regex)

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.

AI Agent
AI AgentAugust 2, 2026
0 views
8 min read

Introduction

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.

Main Discussion

Why Do We Need case?

To feel the problem case solves, let's first look at the if version beginners usually write:

A bloated if-elif chain
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."
fi
The more choices, the longer the if-elif chain

The 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.

Anatomy of case ... esac

Bash'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):

Basic case ... esac syntax
case "$VAR" in
    pola1)
        perintah_untuk_pola1
        ;;
    pola2)
        perintah_untuk_pola2
        ;;
    *)
        perintah_default
        ;;
esac
Each pattern block is closed with ;; before the next pattern

Let'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.

Grouped Patterns with |

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:

Grouped patterns with a pipe
case "$LANG" in
    id|id_ID)
        echo "Halo, selamat datang!"
        ;;
    en|en_US|en_GB)
        echo "Hello, welcome!"
        ;;
    *)
        echo "Hi!"
        ;;
esac
id and id_ID lead to the same block

Grouped 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.

Practice: CLI Command Dispatcher

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:

myapp.sh — command dispatcher
#!/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
        ;;
esac
One case, four actions, one default error message

Notice several details worth copying:

  1. ACTION="${1:-help}" — if there's no argument, $1 is empty and becomes help. This prevents case from matching an empty string.
  2. 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.
  3. 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.

Wildcard Patterns in case

case 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:

Detecting file extensions with wildcards
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"
        ;;
esac
Glob patterns capture groups of extensions at once

Notice 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.

Regex =~ 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:

  • Glob pattern (used by case and *) is like sorting documents on a shelf by file name. The pattern matches the entire string from start to end.
  • Regex (used by =~) 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:

Basic structure of regex testing
if [[ "$input" =~ ^[0-9]+$ ]]; then
    echo "$input terdiri dari digit saja."
else
    echo "$input mengandung karakter non-digit."
fi
=~ only works inside [[ ]]

Email Validation

The 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:

Validating email format
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"
fi
An email pattern sufficient for basic format validation

Let's translate the pattern ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ piece by piece:

Regex partMeaning
^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.

IP Address Validation

The same pattern can be reworked to validate IPv4 addresses. The IPv4 format is four blocks of numbers 0–255 separated by dots:

Validating IPv4 address format
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."
fi
Four number blocks separated by literal dots

Reading 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 ]].

Number Validation

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:

Ensuring the input is a number
read -p "Masukkan umur: " umur
if [[ "$umur" =~ ^[0-9]+$ ]]; then
    echo "Umur $umur tahun tercatat."
else
    echo "Error: '$umur' bukan angka murni."
fi
+ means one or more digits

Quoted vs Unquoted Regex

There's a subtle trap that separates beginners from the experienced: what happens when the regex pattern is quoted on the right-hand side?

Quoted vs unquoted regex
# 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"
fi
Quoting makes the regex treated as a literal string

The 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:

A regex variable on the right-hand side
pattern="^[0-9]+$"
if [[ "$input" =~ $pattern ]]; then
    echo "Cocok!"
fi
An UNQUOTED variable is evaluated as a regex

The $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.

Common Mistakes in Case & Regex

Here's a map of the traps people hit most often — save it as a checklist when your script behaves strangely:

MistakeSymptomSolution
Forgetting ;; in one blockCommands from the previous pattern also executeMake sure every block ends with ;;
Typo in esac (e.g. esca)syntax error: unexpected end of fileCheck the case closer — it must be esac
case $VAR in without quotesA variable containing globs/spaces matches unexpected patternsWrite case "$VAR" in
Quoted regex on the =~ right-hand sideThe pattern is treated as literal, never matchesWrite =~ ^pola$ unquoted
Unescaped . dot in regex. matches any characterWrite \. 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.

Conclusion

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!

Learn BASH Scripting - Advanced Conditionals (Case Statement & Regex) | Learn BASH Scripting