Conditionals: Making Decisions
- Explain how Bash decides truth — exit codes, not booleans — and predict what any if will do
- Write if / elif / else branches with correct test syntax, quoting, and spacing
- Compare numbers and strings with the right operators, and know why mixing them bites
- Test files and directories before acting on them
- Combine conditions and use the modern [[ ]] form where it's safer
- Branch on one value with case, and wrap it in a loop to build a menu
How Bash decides: exit codes are the truth
Build the mental model — a condition is a command, and success means true
Every script you've written so far runs top to bottom, every line, every time. That's a recipe with no judgement — it can't say "only restart the service if it's actually down" or "stop if the backup directory is missing." Conditionals add the judgement. Before the syntax, you need the one idea that makes all of Bash's decision-making click, because it's genuinely different from most languages:
Bash has no true and false. It has exit codes. Every command you run —
ls,grep,mkdir, all of them — finishes with a hidden number called its exit code. Zero means "I succeeded." Anything else means "I failed" (and which non-zero number often says how). You've been generating exit codes all along without looking at them.So what does
ifactually test? `if` runs a command and reads its exit code. Exit0→ thethenbranch runs. Anything else → it doesn't. That's the whole mechanism. Even the square-bracket comparisons you're about to meet aren't special syntax —[is a command (really — trywhich [) whose job is to evaluate a comparison and exit0or1.Think of it like a relay race: each command finishes and hands a baton to whatever's next. A green baton (0) says go; a red baton (non-zero) says don't.
ifis just a runner who looks at the baton before choosing a lane.ls /etc/hostname; echo "exit code: $?"/etc/hostname exit code: 0
The special variable $? holds the exit code of the last command. ls found the file, so it exited 0 — success, "true". Run it against a path that doesn't exist and you'll get a 2 — failure, "false". This one variable is the currency every conditional trades in. Check yourself
In Bash, what does an exit code of 0 mean — and what will
ifdo with it?The if statement
Write a complete if / elif / else and get the unforgiving syntax right
Now the syntax. An
ifneeds four keywords —if,then, the body, andfi(yes,ifspelled backwards — that's how Bash closes it).elifandelseare optional middle floors. Read this shape out loud until it's boring: if this command succeeds, then do these lines, otherwise do those, done.if grep -q "^admin:" /etc/passwd; then echo "admin account exists" elif grep -q "^root:" /etc/passwd; then echo "no admin, but root is here" else echo "neither account found" fino admin, but root is here
- q
- quiet — suppress grep's normal output; we only want the exit code
grep -q searches quietly — no output, just an exit code: 0 if it matched, 1 if not. The first grep fails (no admin user), so Bash falls to the elif; that grep succeeds, so its branch runs and the else is skipped. Notice the semicolon before then — it separates the test command from the keyword when they share a line. Most of the time, though, you're not testing whether a program succeeded — you're comparing values: is this variable empty? is that number bigger than 5? For that, Bash gives you the
testcommand and its famous alias:[. These two lines are identical:```bash
test -n "$name"
[ -n "$name" ]
`Because
[is a command, the shell's normal rules apply — and that explains the syntax errors that bite every newcomer: the spaces inside `[ ]` are mandatory (they separate the command from its arguments), and the closing]is literally the last argument.count=3 if [ "$count" -gt 0 ]; then echo "we have $count items"; fiwe have 3 items
[ receives four arguments: "3", -gt, "0", and ]. It compares, exits 0, and the then branch runs. Cram the brackets against their contents — ["$count" -gt 0] — and Bash hunts for a command named ["3" and fails. The spaces aren't style; they're load-bearing. Compare numbers vs strings — two different toolkits
Pick the right operator family and know what goes wrong when you don't
Here's where Bash differs from every calculator you've used: it has separate comparison operators for numbers and for text, and it will not stop you from using the wrong ones. Numbers use letter operators (
-eq,-gt, …); strings use symbols (=,!=). It feels backwards — stay with it, there's a logic:<and>already mean redirection in the shell, so early Unix picked letters for arithmetic to avoid the clash.The letter operators read as abbreviations: equal, not equal, greater than, greater-or-equal, less than, less-or-equal.
free_mb=512 if [ "$free_mb" -lt 1024 ]; then echo "low memory: ${free_mb}MB"; filow memory: 512MB
-lt is numeric less-than: 512 < 1024, so the branch fires. This is the shape of half the health checks ever written — read a number from the system, compare, act. role="admin" if [ "$role" = "admin" ]; then echo "granting elevated menu"; fi if [ -z "$comment" ]; then echo "comment is empty"; figranting elevated menu comment is empty
- z
- true if the string has zero length (empty or unset)
- n
- true if the string is non-empty
Strings compare with a single = (equality) and != (inequality). Two more string tests earn their keep daily: -z asks "is this empty?" (zero-length) and -n asks "is this non-empty?". $comment was never set, so -z is true. Check yourself
You want to check that $attempts is greater than 3. Which is correct: [ "$attempts" > 3 ] or [ "$attempts" -gt 3 ]?
File tests — look before you leap
Guard every file operation with the right existence or permission test
The most common decision a sysadmin script makes isn't about numbers or strings — it's about the filesystem. Does the config exist? Is it readable? Is that path a directory or a file? Acting without checking is how scripts overwrite data, crash mid-run, or
mkdirinto an error. File tests are single-flag questions you ask[— each one takes a path and answers with an exit code.The ones you'll use constantly:
-eexists (any type),-fregular file,-ddirectory,-r/-w/-xreadable/writable/executable by you, and-snon-empty (size > 0).Example — not runnable on your lab box
if [ -f /etc/redhat-release ]; then echo "RHEL family: $(cat /etc/redhat-release)" else echo "not a RHEL-family system" fiRHEL family: Rocky Linux release 9.3 (Blue Onyx)
- f
- true if the path exists and is a regular file (not a directory or device)
-f asks "is this an existing regular file?" — the standard guard before cat, cp, or source. On Debian/Ubuntu this file doesn't exist, the test exits 1, and the else branch reports honestly instead of cat crashing with 'No such file or directory'. backup_dir="/var/backups/app" if [ ! -d "$backup_dir" ]; then mkdir -p "$backup_dir" echo "created $backup_dir" ficreated /var/backups/app
- d
- true if the path exists and is a directory
- p
- (mkdir) create parent directories as needed; no error if it already exists
! negates any test: [ ! -d ... ] reads "if this directory does NOT exist." Create-if-missing is probably the most-typed conditional in operations. (mkdir -p alone is also idempotent — but the explicit test lets you log, set ownership, or bail differently.) Check yourself
Your script is about to run
source ~/.app/settings.conf. Write the guard that only sources it when it exists, is a regular file, and is readable.Combining conditions and the modern [[ ]]
Join tests with && and ||, and know when double brackets are the better tool
Real decisions are compound: if the file exists AND we're root… if the host is down OR the disk is full… You already know the operators from the command line —
&&(and) and||(or) — and they chain tests exactly the way they chain commands, short-circuiting as they go:&&stops at the first failure,||stops at the first success.Bash also has a second, newer test syntax: double brackets `[[ ]]`. It's not a command like
[— it's shell syntax, and because Bash parses it before expanding variables, the empty-variable crash you met earlier simply can't happen. It also adds two abilities[doesn't have: glob matching with==and regex matching with=~.file="report.csv" if [ -f "$file" ] && [ -s "$file" ]; then echo "processing $file" else echo "skipping: missing or empty" fiskipping: missing or empty
- s
- true if the file exists and its size is greater than zero
Two portable tests joined with &&: the file must exist AND be non-empty (-s) before we bother processing. report.csv isn't there, the first test exits 1, && short-circuits — the -s test never even runs — and the else branch reports why. answer="YES" if [[ ${answer,,} == y* ]]; then echo "confirmed"; fi if [[ "$answer" =~ ^[Yy] ]]; then echo "regex agrees"; ficonfirmed regex agrees
Only [[ ]] can do either of these: == with an unquoted right side does glob matching (y* means "starts with y" — ${answer,,} lowercases first), and =~ matches a full regular expression. Accepting y, Y, yes, YES in one line is the kind of ergonomics that makes [[ ]] the default choice in pure-Bash scripts. Check yourself
Why can [[ -n $name ]] safely skip the quotes around $name when [ -n $name ] can't?
case: one value, many branches
Replace a ladder of elif string tests with a case statement, and put a case inside a loop to build a menu
Some decisions aren't yes-or-no. Is this file a log, an archive, or something else? Did the operator pick 1, 2 or q? You could write that as
if [ "$x" = a ]; elif [ "$x" = b ]; elif …, but Bash has a statement built for it: `case`. It takes one value and compares it against a list of patterns, top to bottom, and runs the commands of the first pattern that matches.`case "$value" in
pattern1) commands ;;
p2|p3) commands ;;
*) fallback ;;
esac
`The pieces: each pattern ends with
), each branch ends with;;, and the block closes withesac(case spelled backwards, likefi). The patterns are globs, the same wildcards as filenames:*is any text,?is one character,[A-Z]is a character class, and|means or. A final*)matches anything that fell through. That makes it your else branch, so input you didn't expect gets a message instead of silence.for code in 200 301 404 503 abc; do case "$code" in 2??) echo "$code: success" ;; 3??) echo "$code: redirect" ;; 4??|5??) echo "$code: error, look at it" ;; *) echo "$code: not a status code" ;; esac done200: success 301: redirect 404: error, look at it 503: error, look at it abc: not a status code
- pattern)
- a glob to test the value against (? is one character); | separates alternatives
- ;;
- end of this branch; case stops here and jumps past esac
- *)
- matches anything: the default branch, keep it last
Five values, one case. 200 matches 2?? (a 2 followed by any two characters) and 301 matches 3??. 404 and 503 both land in the third branch through its | alternative. abc matches none of the specific patterns and falls through to *). Order matters: case stops at the FIRST match, so a catch-all placed early would swallow every value below it. Put a
caseinside awhileloop and you have an interactive menu: print the options,reada choice, dispatch on it withcase, repeat. Two details make a menu robust. `read … || break` leaves the loop when input ends:readfails at end-of-file, so a script fed from a pipe stops cleanly instead of looping forever. And a quit branch that runs `break` gives the operator a way out. (Bash also has aselectbuiltin that prints a numbered menu for you. It uses the same case-inside-a-loop shape.)printf '2\nx\nq\n' | while true; do read -r -p '> ' choice || break case "$choice" in 1) echo "disk" ;; 2) echo "memory" ;; [qQ]) echo "bye"; break ;; *) echo "unknown choice: $choice" ;; esac donememory unknown choice: x bye
- read -r -p '> ' var
- read one line into var; -p prompts (terminal only), -r keeps backslashes literal
- || break
- leave the loop when read fails at end of input
printf stands in for an operator typing 2, then x, then q. Each pass round the loop reads one line and case picks the branch: 2 prints memory, x falls to the *) default, and q matches the character class [qQ], prints bye and runs break. The '> ' prompt doesn't appear because read -p only prints its prompt when input comes from a terminal. Run the loop without the printf pipe to type the choices yourself, and press Ctrl-D to see read || break end it. Check yourself
A case has the branch *) echo other ;; first and 4??) echo error ;; after it. What does it print for 404, and why?
Now do it — gate a real action in your live terminal
Write the guard-then-act pattern yourself and watch both branches fire
Time to make decisions for real. In your terminal, you'll build the single most useful conditional pattern in operations — check, then act, and say what you did — and deliberately trigger both branches so you see the decision happen. Type these; don't paste. The syntax needs to reach your fingers.
cd /tmp && rm -rf cond-lab && mkdir cond-lab && cd cond-labA clean scratch directory, so every test result is one you caused. Working under /tmp means nothing here matters — the safest place to practice destructive-adjacent patterns. if [ ! -d logs ]; then mkdir logs && echo "created logs/"; else echo "logs/ already there"; fiRun this twice. First run: the directory is missing, ! -d is true, you create it. Second run: the else branch fires. Two runs, two branches — you've just watched the same line make two different decisions based on the world's state. echo "disk ok" > logs/status.txt if [ -s logs/status.txt ] && grep -q ok logs/status.txt; then echo "HEALTHY"; else echo "ALERT"; fiA miniature health check: the status file must be non-empty AND contain 'ok'. Now empty the file with : > logs/status.txt and run the if again — ALERT. You've reproduced the guard-and-verify shape of every monitoring script you'll ever read. name="" [ "$name" = "alice" ] && echo quoted-ok [ $name = "alice" ] && echo unquoted-okProvoke the classic crash on purpose: the quoted test evaluates cleanly (false, no output), the unquoted one prints 'unary operator expected'. Seeing the error on your own terms — in a scratch dir, with a one-line cause — is what makes you immune to it in a 200-line script.
- Bash truth is exit codes: 0 = success = true, non-zero = failure = false; if runs a command and branches on that code, so ANY command can be a condition.
- if CONDITION; then ... elif ...; else ...; fi — [ is a command, its spaces are mandatory, and "$var" is always quoted inside it.
- Numbers compare with -eq -ne -gt -ge -lt -le; strings with = != -z -n. Mixing them gives alphabetical nonsense or 'integer expression expected'.
- File tests guard actions: -e exists, -f file, -d directory, -r/-w/-x permissions, -s non-empty; ! negates — test what you're about to do.
- Combine with && / ||; prefer [[ ]] in Bash scripts (no empty-variable crash, == globs, =~ regex) and [ ] when POSIX portability matters.
- case "$x" in pattern) ... ;; *) ... ;; esac picks the first matching glob; put it in a while loop with read ... || break and you have a menu.
| Question | Operator | Example |
|---|---|---|
| Numbers equal / not | -eq / -ne | [ "$n" -eq 0 ] |
| Number bigger / smaller | -gt -ge / -lt -le | [ "$free" -lt 1024 ] |
| Strings equal / not | = / != | [ "$role" = "admin" ] |
| String empty / non-empty | -z / -n | [ -z "$comment" ] |
| Path is file / dir | -f / -d | [ -f /etc/fstab ] |
| Readable / writable / executable | -r / -w / -x | [ -r "$conf" ] |
| Pattern / regex match | == / =~ (only in [[ ]]) | [[ $a == y* ]] |
| One value, many choices | case ... esac | case "$c" in 1) ... ;; *) ... ;; esac |
