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"
fiAn 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"
fi6.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%"
fiBash 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"
fiThe 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"
fiThis 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"
fibash: 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 fileBoth 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).
| Operator | Meaning |
|---|---|
-eq | equal to |
-ne | not equal to |
-gt | greater than |
-ge | greater than or equal to |
-lt | less than |
-le | less than or equal to |
process_count=15
if [ "$process_count" -gt 10 ]; then
echo "Number of processes is above the threshold"
fiThese 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"
fiA 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.
| Operator | Meaning |
|---|---|
= or == | exact string match |
!= | string does not match |
-z | empty string (zero length) |
-n | non-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"
fiNote 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"
fiInside [[ ]], 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"
fiOne 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.
| Operator | Meaning |
|---|---|
-e | path exists, regardless of type (file, directory, etc.) |
-f | path exists and is a regular file |
-d | path exists and is a directory |
-r | path exists and is readable |
-w | path exists and is writable |
-x | path exists and is executable |
-s | path exists and its size is greater than zero |
-L | path 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"
fiA 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"
fiThe 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"
;;
esacThe ;; 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"
;;
esacMultiple 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..."
;;
esacPatterns 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"
;;
esacSince 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"
;;
esacThe 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..."
;;
esacThe 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"
;;
esacThe 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"
fiKeep 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
fiBash 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"
fi6.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
fiThe 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
fi6.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
;;
esacNotice 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 restartAlso 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.

