Conditionals: if & case

Branching on tests and patterns.

Beginner15 min · lesson 5 of 14

Branching: picking a path

Every useful script reaches a fork in the road. A file is there, or it isn't. A service came up, or it didn't. The person running your script typed the right word, or they fat-fingered it. Branching is how a script looks at the situation in front of it and picks which path to take, instead of running every line from top to bottom no matter what. Bash gives you two tools for this. Reach for `if` when the question is yes-or-no. Reach for `case` when the question is "which one of these is it?" You will use both every day you write operations scripts (the small programs that start services, check configs, and keep servers healthy).

if reads an exit code, not a value

Here is the part that trips up almost everyone coming from another language. In most languages, `if` checks whether something is true or false. In Bash, `if` runs a command and checks how that command *ended*. Every command hands back a small number when it finishes, called the exit code (a whole number from 0 to 255 that reports success or failure). Zero means "all good." Anything else means "something was off." It works backwards from what you would guess, because zero is the happy answer. Think of a bouncer at a door who gives one of two signals: a thumbs-up or a thumbs-down. The thumbs-up is zero. Everything else is a thumbs-down. `if` watches nothing but that thumb.

~/secopslog — bash
$ # grep -q stays quiet; its only job is to set the exit code grep -q "PermitRootLogin no" sshd_config echo $? grep -q "PermitRootLogin yes" sshd_config echo $?
0 1

`$?` is a special variable that holds the exit code of the command that just ran. `grep -q` (quiet grep, the text-search tool told to keep its mouth shut) prints nothing at all; its whole purpose is to set that code. The first search found the line, so it ended with 0. The second found nothing, so it ended with 1. Now watch `if` feed on exactly that number.

if / elif / else / fi

`if` runs a command. If the command ends in success (zero), Bash runs the block up to `fi`. `fi` is `if` spelled backwards, and that really is how you close the block. A common first question in operations work is whether a configuration file is even there before you try to read it. Here you look for `sshd_config`, the settings file for the SSH (Secure Shell, the standard tool for logging into a machine over the network) server.

~/secopslog — bash
$ CONFIG="sshd_config" if [[ -f "$CONFIG" ]]; then echo "Found $CONFIG" else echo "Missing $CONFIG" fi
Found sshd_config

`[[ ... ]]` is Bash's built-in test. It is a command like any other: it checks the condition inside and ends with 0 or non-zero, which is exactly what `if` wants. `-f` asks "is this a regular file that exists?" When it is, you take the `then` branch; when it isn't, you take `else`. Point the same script at a path that isn't there and the other branch fires.

~/secopslog — bash
$ CONFIG="/etc/does-not-exist.conf" if [[ -f "$CONFIG" ]]; then echo "Found $CONFIG" else echo "Missing $CONFIG" fi
Missing /etc/does-not-exist.conf

When there are more than two paths, `elif` (short for else-if) stacks extra questions underneath, like running down a checklist and stopping at the first line you can tick. Bash reads them top to bottom and takes the first one that succeeds, skipping the rest. Say you want to describe which port SSH is listening on.

~/secopslog — bash
$ port=22 if (( port == 22 )); then echo "SSH on the default port 22" elif (( port == 2222 )); then echo "SSH on the common alt port 2222" else echo "SSH on a custom port: $port" fi
SSH on the default port 22

`(( ... ))` is arithmetic mode, meant for comparing numbers with `==`, `<`, `>=`, and their friends. Inside it you drop the `$` in front of variable names. Use `(( ))` for math and `[[ ]]` for files, strings, and text; mixing them up is a frequent beginner slip.

Combining tests with && and ||

One question is often not enough. You might want "the config exists AND root login is turned off" before you call a server safe. `&&` means and; `||` means or. Bash is lazy about both in a way that helps you. With `&&`, if the left side fails, it never runs the right side, the same way you don't reach for the handle once you find the door is locked. That laziness is the point.

~/secopslog — bash
$ CONFIG="sshd_config" if [[ -f "$CONFIG" ]] && grep -q "PermitRootLogin no" "$CONFIG"; then echo "Config present and root login off" fi
Config present and root login off

The short-circuit is a safety feature. The `grep` only runs once you already know the file is there, so you never search a file that doesn't exist and then act on the empty result. You can flip the idea into a guard clause at the top of a script: `[[ -f "$CONFIG" ]] || exit 1` reads as "the file is there, or else bail out now." Guard clauses like that stop a script before it does damage on bad input.

Quoting in a test: which bracket bites, and how
Get in the habit of wrapping a variable in double quotes inside a test, and know why, because the two kinds of test fail differently. The older single-bracket test `[ ... ]` splits an unquoted variable into separate words before it runs. If `$CONFIG` is empty, `[ -f $CONFIG ]` shrinks to `[ -f ]`, which reads `-f` as a plain non-empty string and quietly reports true, waving bad input straight through. If `$CONFIG` holds a path with a space, it splits into two words and the test errors out with "binary operator expected." The double-bracket `[[ ... ]]` never word-splits, so it handles empty and spaced values without flinching, which is the main reason to prefer it. But `[[ ]]` carries its own trap: on the right side of `==` or `=~`, an unquoted value is read as a match pattern, not plain text, so `[[ $name == $wanted ]]` with `wanted="a*"` matches anything starting with "a". Quote by default and you dodge every version of this.

case: one value, many patterns

A tall stack of `elif` lines that all compare the very same variable is a code smell (a sign the shape of your code is fighting you). `case` is built for that exact shape. It takes one value, matches it against a list of patterns, and runs the block for the first pattern that fits, the way a mail sorter drops each letter into a bin by its ZIP code (the postal routing number). Here is the small service-control script you will meet everywhere in operations work: it reads one word and branches on it.

svc.sh
#!/usr/bin/env bash
action="$1"
case "$action" in
start|up)
echo "Starting service..."
;;
stop|down)
echo "Stopping service..."
;;
status)
echo "Service is running (pid 4123)"
;;
reload|restart)
echo "Reloading config and restarting..."
;;
*)
echo "Usage: $0 {start|stop|status|restart}" >&2
exit 1
;;
esac

`$1` is the first argument the caller typed after the script name. Each pattern ends in a `)`. The `|` between words means or, so `start|up` matches either one. The `;;` closes a branch (a pair of semicolons, easy to forget and a classic source of confusing errors). `*` is a wildcard that matches anything, which makes the `*)` block a catch-all default at the bottom. `esac` is `case` backwards, closing the whole structure. Run it against a couple of real branches and one unknown word.

~/secopslog — bash
$ ./svc.sh start ./svc.sh status ./svc.sh frobnicate echo $?
Starting service... Service is running (pid 4123) Usage: ./svc.sh {start|stop|status|restart} 1

That `*)` default is doing real security work, not decoration. It is the line between failing closed and failing open. Failing closed means that when the input is something you never planned for, you refuse it and exit non-zero (the `exit 1` above), so nothing downstream treats a typo or a hostile argument as a valid command. A lock that jams shut when it can't read the key is safer than one that pops open. A script with no default, or one whose default quietly does nothing, fails open, and unexpected input slides through unhandled. Sending the usage message to `>&2` (standard error, the separate output stream meant for diagnostics rather than results) keeps that noise out of any real output another program might be reading. Reject unknown input on purpose.

One sharp edge worth naming: `case` patterns are glob patterns (the simple wildcards the shell uses to match filenames), not regular expressions. `*` matches any run of characters, `?` matches exactly one, and `[...]` matches one character from a set. That makes `case` a clean way to route by file type, which comes up constantly when you decide what is safe to log, rotate, or commit to version control.

~/secopslog — bash
$ classify() { case "$1" in *.key|*.pem) echo "$1 -> secret, never log or commit" ;; *.log) echo "$1 -> log file, safe to rotate" ;; *.conf|*.cfg) echo "$1 -> config, review before shipping" ;; *) echo "$1 -> unknown type, handle with care" ;; esac } classify server.key classify access.log classify backup.tar.gz
server.key -> secret, never log or commit access.log -> log file, safe to rotate backup.tar.gz -> unknown type, handle with care
How the service script routes one argument
"$1" (the argument)
case checks it against each pattern in order
start|up
Start the service
prints status, exits 0
stop|down
Stop the service
prints status, exits 0
status
Report running state
prints the process ID (pid), exits 0
* (anything else)
Print usage to stderr
exit 1 — refuse unknown input
case reads $1 top to bottom and runs the first pattern that matches; the * default fails closed, refusing anything unexpected.
Quick check
01In Bash, `if some_command; then ...` runs the `then` block when:
Incorrect — No. `if` never reads what a command prints to the screen. It only looks at the exit code the command returns when it finishes.
Correct — Zero means success, and `if` branches on the exit code, not on any text the command printed.
Incorrect — Backwards. Non-zero signals failure, which sends `if` to the `else` branch (or past the `fi` if there is no `else`).
Incorrect — No. Bash runs the command and inspects how it ended. Checking whether a variable is non-empty is a different test, like `[[ -n "$x" ]]`.
02The lesson writes `if (( port == 22 ))`. What is true about testing numbers inside `(( ... ))`?
Incorrect — arithmetic mode lets you drop the $ entirely; you write the bare name.
Correct — (( )) is meant for math, so `port` is written without a dollar sign.
Incorrect — (( )) is for numbers; [[ ]] is the tool for strings and files.
Incorrect — it works with variables, as `port` in the example shows.
03Given `case "$f" in *.log) echo rotate ;; *) echo skip ;; esac`, what does it print when `f="app.log.1"`?
Incorrect — the glob *.log matches only values that end in .log, not ones that merely contain it.
Incorrect — case runs only the first matching branch, never several.
Incorrect — the *) catch-all matches anything, so there is always a match.
Correct — case patterns are globs matched across the whole value, and *.log requires the string to end in .log.

Related