Signal9Signal9 — home
online
all lessons

Conditionals: Making Decisions

Beginner45 min7 steps
What you'll learn
  • 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
  1. 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 if actually test? `if` runs a command and reads its exit code. Exit 0 → the then branch 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 — try which [) whose job is to evaluate a comparison and exit 0 or 1.

    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. if is 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 if do with it?

  2. The if statement

    Write a complete if / elif / else and get the unforgiving syntax right

    Now the syntax. An if needs four keywords — if, then, the body, and fi (yes, if spelled backwards — that's how Bash closes it). elif and else are 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"
    fi
    no 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 test command 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"; fi
    we 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.
  3. 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"; fi
    low 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"; fi
    granting 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 ]?

  4. 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 mkdir into 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: -e exists (any type), -f regular file, -d directory, -r/-w/-x readable/writable/executable by you, and -s non-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"
    fi
    RHEL 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"
    fi
    created /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.

  5. 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"
    fi
    skipping: 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"; fi
    confirmed
    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?

  6. 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 with esac (case spelled backwards, like fi). 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
    done
    200: 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 case inside a while loop and you have an interactive menu: print the options, read a choice, dispatch on it with case, repeat. Two details make a menu robust. `read … || break` leaves the loop when input ends: read fails 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 a select builtin 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
    done
    memory
    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?

  7. 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-lab
    A 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"; fi
    Run 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"; fi
    A 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-ok
    Provoke 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.
Recap
  • 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.
The comparison toolbox
QuestionOperatorExample
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 choicescase ... esaccase "$c" in 1) ... ;; *) ... ;; esac