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.
[ ... ] 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.
#!/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.
if some_command; then ... runs the then block when:if never reads what a command prints to the screen. It only looks at the exit code the command returns when it finishes.if branches on the exit code, not on any text the command printed.if to the else branch (or past the fi if there is no else).[[ -n "$x" ]].if (( port == 22 )). What is true about testing numbers inside (( ... ))?port is written without a dollar sign.port in the example shows.case "$f" in *.log) echo rotate ;; *) echo skip ;; esac, what does it print when f="app.log.1"?Try this
Work through “case: one value, many patterns” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.
Takeaway
The trap worth remembering here: quoting in a test: which bracket bites, and how. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.