Branching & Conditions

Branching & Conditions

Bitnesia • Aug 30, 2026 88 •ID

Scripts we have written from Chapter 1 through Chapter 5 still run linearly from top to bottom, with every line of command executed as is regardless of the system condition at that time. Real-world scenarios are not that simple. Sysadmins need to check whether a service is actually running before sending a notification that the service is down, users need to ensure the source file exists before copying it, and developers need to distinguish the staging environment from production before running a deployment command. This chapter discusses branching, which is the ability of a script to make decisions and run different blocks of code depending on the conditions met. We will start from the if/elif/else structure along with two forms of condition operators ([ ] and [[ ]]), move on to various comparison operators for numbers, strings, and files, enter the case statement to simplify multiple conditions at once, and conclude with exit statuses as well as logical operators && and || which form the foundation of decision-making in Bash.

6.1 if, elif, else, and Condition Operators

This section covers the most basic branching structure in Bash, which is if, along with two forms of operators used to test its conditions.

6.1.1 Basic Structure of if

The if structure executes a command block only if the tested condition is met. Its simplest form uses then to mark the beginning of the block and fi (the reverse spelling of if) to close it.

if [ -d "/etc/nginx" ]; then
    echo "Nginx configuration directory found"
fi

An important detail to understand from the start: if does not actually evaluate a boolean value like in other programming languages. if executes a command, in the example above the command is [ -d "/etc/nginx" ], and then checks the exit status of that command. If the exit status is 0 (success), the then block is executed. If not, the block is skipped. This exit status concept will be thoroughly discussed in section 6.4, but it is important to remember now because this is the real mechanism behind every Bash branching.

The semicolon before then in the example above can be replaced with a new line, a writing style that is also common, especially for long conditions.

if [ -d "/etc/nginx" ]
then
    echo "Nginx configuration directory found"
fi

6.1.2 Adding elif and else

else runs an alternative block when the if condition is not met, while elif (short for "else if") adds subsequent conditions without needing to close and open a new if separately.

disk_usage=92

if [ "$disk_usage" -ge 90 ]; then
    echo "Critical: disk usage is already $disk_usage%"
elif [ "$disk_usage" -ge 75 ]; then
    echo "Warning: disk usage is already $disk_usage%"
else
    echo "Disk usage is still safe: $disk_usage%"
fi

Bash evaluates the if condition and each elif sequentially from top to bottom, and stops immediately upon finding the first condition that is met, ignoring the rest. The number of elif is not limited, but in practice, a chain of elif that is too long to check many values of the same variable is usually better written using case, a topic covered in section 6.3.

6.1.3 Test Builtin [ ] and Keyword [[ ]]

Bash provides two ways to write conditions: [ ] and [[ ]]. Both often look similar, but are fundamentally different at the shell level.

[ ] is another form of the test command, a Bash builtin that is also available as an external command (usually in /usr/bin/test) for compatibility with POSIX shells like sh or dash. Because of its status as a regular command, [ ] is subject to the same shell parsing rules as other commands, including word splitting and pathname expansion on unquoted variables (rules already discussed in Chapter 3.4).

nama=""
if [ $nama = "budi" ]; then
    echo "Match"
fi

The line above will fail with an error message like [: =: unary operator expected because the empty and unquoted $nama variable causes the test command to receive an incomplete set of arguments. This is one of the most common pitfalls for beginners.

[[ ]] is a Bash keyword (not a regular command), an additional feature that is also available in ksh but not part of the POSIX shell standard. Because the Bash parser processes it directly, rather than through regular command parsing rules, [[ ]] is immune to word splitting and pathname expansion on unquoted variables inside it.

nama=""
if [[ $nama = "budi" ]]; then
    echo "Match"
else
    echo "No match"
fi

This line runs safely even though $nama is empty and unquoted, because [[ ]] still treats it as a single argument (in this case an empty string), not a missing argument.

6.1.4 When to Choose [[ ]] over [ ]

A rule of thumb widely followed among Bash script writers, including those recommended by ShellCheck: use [[ ]] by default for scripts specifically written for Bash (shebang #!/bin/bash, according to Chapter 1.4), because it is safer against unquoted variables and supports extra features like pattern matching and regular expressions with =~. Use [ ] only if the script must be portable to pure POSIX shells like sh or dash, such as legacy system init scripts or target environments that do not guarantee the availability of Bash. One important note: [[ ]] cannot be used inside scripts with the shebang #!/bin/sh on systems where sh is not Bash (for example, Debian and its derivatives that use dash as /bin/sh), because this keyword is purely a Bash feature.

6.1.5 Troubleshooting: Syntax Errors in if/then/fi

The following two syntax errors appear most frequently when getting used to writing if, and both produce error messages that can be quite confusing for beginners because they do not point directly to the line that is actually wrong. The first error is forgetting to write then on the same line as the condition (separated by a semicolon) or on the next line separately.

if [ -d "/etc/nginx" ]
    echo "Directory found"
fi
bash: syntax error near unexpected token `fi'

The error message above looks strange because it points to the fi line instead, whereas the root cause is in the first line that is not followed by then. This happens because Bash considers [ -d "/etc/nginx" ] and echo "Directory found" as two regular commands that are still part of the if condition, keeps waiting for the keyword then to appear, and only reports an error once it encounters fi without ever finding the expected then. The second error is forgetting to close the structure with fi altogether, which makes Bash assume the script is not finished even though it has reached the last line.

bash: syntax error: unexpected end of file

Both errors share similar symptoms: the message shown points to a location far from the original line of the issue. A habit that helps prevent this is immediately typing the pair if...fi (or then after the condition) altogether before filling in the lines in between, rather than writing from top to bottom and then forgetting to close it.

6.2 Comparing Numbers, Strings, and Files

This section discusses comparison operators used inside [ ] as well as [[ ]], grouped by the type of data being compared: numbers, strings, and file attributes.

6.2.1 Numeric Comparison Operators

The test builtin uses letter-based operators for numeric comparison, not symbols like == or >, because those symbols are already used for other purposes (string comparison and redirection).

OperatorMeaning
-eqequal to
-nenot equal to
-gtgreater than
-gegreater than or equal to
-ltless than
-leless than or equal to
process_count=15

if [ "$process_count" -gt 10 ]; then
    echo "Number of processes is above the threshold"
fi

These operators only work for integers and do not support decimal numbers. Comparing 3.5 -gt 3 will produce an error integer expression expected, because the test builtin does not know how to interpret a decimal point as part of a number. For decimal number comparison, we need external tools like awk or bc, a topic covered further in Chapter 11. As an alternative for integer comparison, Bash also provides arithmetic evaluation with (( )) which was briefly mentioned in Chapter 3.3, and supports standard mathematical symbols like >, <, and ==.

if (( process_count > 10 )); then
    echo "Number of processes is above the threshold"
fi

A common beginner mistake is using == to compare numbers inside [ ] or [[ ]], when == is a string comparison operator, not a numeric one. Coincidentally "10" == "10" still produces the correct result as a string, but "010" == "10" will be considered not equal because they are compared as literal text, not numeric values.

6.2.2 String Comparison Operators

String comparison uses symbols, not letter-based operators like numbers.

OperatorMeaning
= or ==exact string match
!=string does not match
-zempty string (zero length)
-nnon-empty string
<lexicographically smaller (dictionary order)
>lexicographically greater
service_status="active"

if [ "$service_status" = "active" ]; then
    echo "Service is running"
fi

if [ -z "$service_status" ]; then
    echo "Service status is not yet known"
fi

Note that inside [ ], the == operator is actually a Bash extension that is not guaranteed to exist in all POSIX test implementations, so a single = is more portable if the target script is indeed a pure POSIX shell. Inside [[ ]], both are equivalent and equally safe to use.

The < and > operators inside [ ] must be escaped with a backslash (\< and \>), because without an escape, the shell will interpret them as redirection operators as discussed in Chapter 5.3, rather than as part of the stream for the test command.

if [ "abc" \< "abd" ]; then
    echo "abc comes first in dictionary order"
fi

Inside [[ ]], this escaping is not required because the Bash parser already knows both are within a conditional context, not redirection.

if [[ "abc" < "abd" ]]; then
    echo "abc comes first in dictionary order"
fi

One important difference to know: inside [[ ]], the unquoted right-hand side of the == or != operator is treated as a glob pattern, not a literal string. This means [[ "app.log" == *.log ]] evaluates to true because *.log is treated as a pattern, not literal text. This is useful for simple pattern matching, but can be a trap if we actually want to compare a string as is. Quote the right-hand side with quotation marks if a literal comparison is intended, for example [[ "$file_name" == "*.log" ]].

6.2.3 File Test Operators

File test operators check the existence and attributes of a file or directory in the filesystem, very frequently used by Sysadmins before performing operations that depend on that file.

OperatorMeaning
-epath exists, regardless of type (file, directory, etc.)
-fpath exists and is a regular file
-dpath exists and is a directory
-rpath exists and is readable
-wpath exists and is writable
-xpath exists and is executable
-spath exists and its size is greater than zero
-Lpath exists and is a symbolic link
config_file="/etc/app/config.conf"

if [ -f "$config_file" ] && [ -r "$config_file" ]; then
    echo "Configuration file found and readable"
else
    echo "Configuration file not found or not readable"
fi

A mistake often encountered in the field is using -e when actually intending to ensure a path is a regular file. -e still evaluates to true even if the path turns out to be a directory, so subsequent operations such as reading file contents with cat might fail with a confusing error message. Use -f specifically if targeting a regular file, and -d if targeting a directory.

6.2.4 Troubleshooting: Unquoted Variable Pitfalls

The most common mistake across this entire section is forgetting to quote variables inside [ ]. In addition to the empty string case discussed in section 6.1.3, variables containing spaces can also break branching logic.

file_name="monthly report.txt"

if [ -f $file_name ]; then
    echo "File found"
fi

The line above will fail with a too many arguments error, because unquoted $file_name undergoes word splitting into two separate arguments (monthly and report.txt), while -f expects only one path argument. The solution is consistent with what was discussed in Chapter 3.4: always quote variables with double quotes, [ -f "$file_name" ]. This habit is still recommended even when using [[ ]] which is actually immune to word splitting, to stay consistent and avoid confusion when switching between [ ] and [[ ]] in the same script.

6.3 case Statement for Multiple Conditions

This section discusses case, a branching structure that is more concise than a long if/elif chain when we need to check a single value against many possibilities.

6.3.1 Basic Syntax of case

A case statement matches a single value against a series of patterns, then executes the command block for the first matching pattern. Its structure ends with the keyword esac, the reverse spelling of case.

read -rp "Select action (start/stop/status): " action

case "$action" in
    start)
        echo "Starting service..."
        ;;
    stop)
        echo "Stopping service..."
        ;;
    status)
        echo "Checking service status..."
        ;;
    *)
        echo "Unrecognized action: $action"
        ;;
esac

The ;; symbol closes each pattern block, marking the end of that block while breaking execution out of the entire case, without checking subsequent patterns. The *) pattern on the last line acts as a default case, matching any value not caught by previous patterns, similar to the role of else in if.

6.3.2 Pattern Matching and Combined Patterns

Patterns in case use the same glob pattern rules as filename matching in the shell (not regular expressions), with * matching any string, ? matching a single character, and [...] matching a single character from a specified set of characters.

file_name="report.log"

case "$file_name" in
    *.txt)
        echo "Plain text file"
        ;;
    *.log)
        echo "Log file"
        ;;
    backup_*)
        echo "Backup file"
        ;;
    *)
        echo "Unrecognized file type"
        ;;
esac

Multiple patterns can be combined in a single block using the | symbol as a delimiter, useful when several different values must indeed be treated the same way.

case "$action" in
    -h|--help)
        echo "Usage: script.sh [start|stop|status]"
        ;;
    start|restart)
        echo "Starting (restarting) service..."
        ;;
esac

Patterns like -h|--help are very commonly used when handling script arguments, a topic covered more fully in Chapter 12.

6.3.3 Fall-Through with ;& and ;;&

In addition to ;;, Bash since version 4.0 provides two extra terminators for more specific needs: ;& and ;;&. Because these features require Bash 4 or higher, make sure the target Bash version meets the requirement (check with bash --version, discussed in Chapter 1.3) before using them in scripts that might be run in older environments.

;& forcefully makes execution "fall through" to the next block without testing that block's pattern, similar to fall-through in switch statements in several other programming languages.

level=2

case "$level" in
    1)
        echo "Level 1"
        ;&
    2)
        echo "Level 2"
        ;&
    3)
        echo "Level 3"
        ;;
esac

Since level has a value of 2, the output above displays Level 2 and then also displays Level 3, because ;& in block 2) forces execution to continue into block 3) without matching its pattern first.

;;& is slightly different: after running a matching block, Bash continues matching subsequent patterns (rather than just running the next block unconditionally), and can execute more than one block if multiple patterns match.

file_name="backup_report.log"

case "$file_name" in
    *.log)
        echo "This is a log file"
        ;;&
    backup_*|*backup*)
        echo "This is a backup file"
        ;;
esac

The output above displays both lines because file_name matches the *.log pattern as well as the *backup* pattern, and ;;& in the first block causes Bash to keep checking the second pattern instead of stopping. These two terminators are rarely used for everyday cases, but are useful when a value needs to trigger multiple actions at once based on non-mutually exclusive categories.

6.3.4 Troubleshooting: Pattern Order and Variables inside Patterns

An error that often goes unnoticed in case is the order of writing patterns. Bash matches patterns sequentially from top to bottom and immediately runs the first block that matches, regardless of whether there are other more specific patterns below it.

action="start"

case "$action" in
    *)
        echo "Unrecognized action"
        ;;
    start)
        echo "Starting service..."
        ;;
esac

The script above always displays Unrecognized action even when action equals start, because the *) pattern placed on the first line catches all possible values first, so the start) block below it will never be reached. A mandatory rule of thumb to remember: the most specific pattern must be placed first, and the catch-all pattern *) must always be at the very end.

A second, subtle trap arises when a variable is used as part of a pattern, rather than as the tested value. An unquoted variable in a pattern position is still treated as a glob, so characters like * or ? inside its value function as wildcards rather than literal text.

prefix="backup*"
file_name="daily_backup.log"

case "$file_name" in
    $prefix)
        echo "Matches as a pattern"
        ;;
    *)
        echo "Does not match"
        ;;
esac

The line above displays Matches as a pattern, because the * character inside $prefix still acts as a wildcard when unquoted. If literal matching against the text backup* as is (including the asterisk) is intended, quote that variable in the pattern position, for example "$prefix"), so that the characters inside it lose their special meaning as globs.

6.4 Exit Status and Logical Operators

This concluding section discusses exit status in greater depth, a concept mentioned repeatedly since section 6.1 as it forms the basis of the entire Bash branching mechanism, along with two logical operators that use it directly.

6.4.1 The $? Variable and Exit Status Conventions

Every command finished executing in Bash leaves an exit status, a number indicating whether that command succeeded or failed. The universally applicable convention across Unix-like systems: 0 means success, while non-zero numbers (1 to 255) mean failure, with the specific meaning of each number usually depending on the command returning it. The built-in variable $? stores the exit status of the command that completed most recently.

grep -q "error" /var/log/app.log
echo "Grep exit status: $?"

grep returns 0 if the pattern is found, and 1 if not found at all, which is why grep is very often used directly as an if condition without storing its output in a variable first (the -q option above suppresses grep output to screen, returning only the exit status).

if grep -q "error" /var/log/app.log; then
    echo "Error line found in log"
fi

Keep in mind that $? always refers to the absolute last command executed, so its value is easily overwritten accidentally. Save it immediately to another variable if its value is still needed after executing other commands, including simple commands like echo to display messages.

./deploy.sh
deploy_status=$?
echo "Checking deployment process..."

if [ "$deploy_status" -ne 0 ]; then
    echo "Deployment failed with exit status $deploy_status" >&2
fi

Bash limits exit status numbers to an 8-bit range, from 0 to 255. If a command or script exits using exit with a number outside that range, Bash truncates it to that range (modulo 256), so exit 256 in practice yields an exit status of 0, exactly like success, even if the intention was to mark failure. A safe habit is to always explicitly use numbers 1 through 255 to mark failure, and keep their meanings consistent throughout the script as much as possible.

6.4.2 Operators && and || for Short-Circuiting

&& executes the second command only if the first command succeeds (exit status 0), whereas || executes the second command only if the first command fails (non-zero exit status). This behavior is called short-circuit evaluation, because Bash does not need to evaluate the second command at all if the overall outcome can already be determined from the first command.

mkdir -p /tmp/daily_backup && echo "Directory ready for use"

The || operator is useful for giving feedback when a command fails, for instance checking connectivity to another server (combining redirection from Chapter 5.3 with logical operators here).

ping -c 1 server1.example.com > /dev/null 2>&1 || echo "Server1 cannot be reached"

Both operators can be chained for a "if successful do A, if failed do B" pattern, a pattern very commonly used by Sysadmins for quick validation before running subsequent operations.

cd /var/www/app && git pull || echo "Failed to enter directory or pull updates"

One trap to watch out for: if the first command in an A && B pattern itself fails for various different reasons, the || chain at the end cannot distinguish whether A or B failed. For validation requiring clearer feedback, a standard if structure covered in section 6.1 is easier to read and debug than chaining too many && and || operators on a single line.

As an additional note, the test builtin [ ] actually recognizes logical operators -a (AND) and -o (OR) to combine two conditions inside a single pair of square brackets, but POSIX has marked both as obsolescent and they should be avoided in new scripts. Use && or || to combine two separate [ ] or [[ ]] blocks instead.

# Avoid this old pattern
if [ -f "$file" -a -r "$file" ]; then
    echo "File exists and is readable"
fi

# Recommended alternative
if [ -f "$file" ] && [ -r "$file" ]; then
    echo "File exists and is readable"
fi

6.4.3 Troubleshooting: $? Accidentally Overwritten

Because $? records only the exit status of the absolute last command executed (as mentioned in section 6.4.1), a frequently overlooked mistake is inserting another command, no matter how small, between the command being checked and the $? check itself.

./deploy.sh
echo "Checking deployment status..."

if [ "$?" -eq 0 ]; then
    echo "Deployment successful"
else
    echo "Deployment failed" >&2
fi

The script above almost always reports Deployment successful, regardless of the actual result of ./deploy.sh, because the line echo "Checking deployment status..." executes right before $? is checked, and echo almost always succeeds (exit status 0), thereby overwriting the exit status of ./deploy.sh that was meant to be tested. The solution is consistent with what was discussed in section 6.4.1: capture $? into another variable immediately after the command to check completes, before executing any other commands, including echo.

./deploy.sh
deploy_status=$?
echo "Checking deployment status..."

if [ "$deploy_status" -eq 0 ]; then
    echo "Deployment successful"
else
    echo "Deployment failed" >&2
fi

6.4.4 Combining if, case, and Exit Status in a Script

To conclude the chapter, let us combine if, case, file comparison operators, and exit statuses into a small script that checks the readiness of a project directory before executing a specific action, a simple scenario representing validation patterns that will recur in subsequent chapters.

#!/bin/bash

project_directory="/var/www/app"
action="${1:-status}"

if [ ! -d "$project_directory" ]; then
    echo "Error: project directory $project_directory not found" >&2
    exit 1
fi

case "$action" in
    status)
        if [ -f "$project_directory/.git/HEAD" ]; then
            echo "Project found and is a Git repository"
        else
            echo "Project found, but is not a Git repository"
        fi
        ;;
    deploy)
        cd "$project_directory" && git pull
        if [ "$?" -eq 0 ]; then
            echo "Deployment successful"
        else
            echo "Deployment failed" >&2
            exit 1
        fi
        ;;
    *)
        echo "Unrecognized action: $action. Use 'status' or 'deploy'." >&2
        exit 2
        ;;
esac

Notice the exclamation mark in [ ! -d "$project_directory" ] at the beginning line; this mark negates the test result, so the if block runs precisely when the directory is not found. The script exits immediately with exit 1 under this condition, preventing the case statement below it from executing with an invalid directory. Save this as check_project.sh, grant execution permissions, and run it with the desired action argument.

chmod +x check_project.sh
./check_project.sh status
./check_project.sh deploy
./check_project.sh restart

Also run echo $? immediately after each execution to observe how the exit status differs depending on the outcome: success returns 0, an unrecognized action returns 2, and a deployment process failure returns 1. Distinguishing exit statuses like this is useful when the script is invoked by another script or by cron in Chapter 18, as the caller can make subsequent decisions based on that specific number, rather than just knowing a generic "failure". The pattern of using if for initial validation, case for handling multiple actions, and distinct exit statuses for each failure type will be used repeatedly, including when moving on to loops in Chapter 7 and formal script argument handling in Chapter 12.