Conditionals: if & case
Branching on tests and patterns.
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.
`$?` 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.
`[[ ... ]]` 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.
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.
`(( ... ))` 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.
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.
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.
#!/usr/bin/env bashaction="$1"case "$action" instart|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}" >&2exit 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.
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.