Conditions and Loops
Variables and Arguments already used the [ -z "$1" ] test once — to check whether an argument was empty or missing. This article opens up the full logic behind exactly these tests, the if construct that makes decisions based on them, and the for/while loops that repeat an action multiple times. These three — tests, conditionals, and loops — form the skeleton of any serious Bash script.
What You Will Learn
By the end of this article you will be able to:
- check files, strings, and numbers with
[ ]and[[ ]]test syntax; - build
if,if/else,if/elif/elseconstructs; - write a more compact multi-way choice with
case; - write a
forloop over list elements and awhileloop that runs until a condition holds; - combine tests and loops into a real checking script.
Tests: Conditional Expressions
A test is an expression enclosed in brackets that returns a true or false result (exit status):
If /etc/passwd exists, this test ends with exit code 0 (success); otherwise, 1. The general logic of exit codes is covered in more depth in Exit Codes and Error Handling; what matters here is that the test itself is, in fact, an ordinary command (a shorthand for the test command), so its result can also be checked via $?.
Bash has two test syntaxes: the classic [ ] (POSIX-compatible, the test command itself) and the extended [[ ]] (Bash-specific only). There must always be a space between the opening and closing brackets and the expression — [-e /etc/passwd] fails.
File Tests
| Flag | Checks |
|---|---|
-e |
the file exists (any type) |
-f |
a regular file exists (not a symlink) |
-d |
a directory exists |
-r |
readable |
-w |
writable |
-x |
executable |
-s |
the file exists and isn't empty |
String Tests
| Expression | Checks |
|---|---|
-z "$VAR" |
the string is empty |
-n "$VAR" |
the string is not empty |
"$A" = "$B" |
the two strings are equal |
"$A" != "$B" |
the two strings are not equal |
Numeric Tests
| Expression | Checks |
|---|---|
-eq |
equal |
-ne |
not equal |
-lt |
less than |
-le |
less than or equal |
-gt |
greater than |
-ge |
greater than or equal |
Warning
Use =/!= for strings and -eq/-ne/-lt/-gt for numbers — they aren't interchangeable. "10" -eq "9" compares numerically, while "10" = "9" compares as strings; the two can give different results — for example, "010" and "10" are numerically equal but not equal as strings.
[[ ]]: the Extended Test
[[ ]] only works in Bash (and other modern shells like zsh) and offers several conveniences:
if [[ -f /etc/nginx/nginx.conf && -r /etc/nginx/nginx.conf ]]; then
echo "The file exists and is readable"
fi
if [[ "$HOSTNAME" == web-* ]]; then
echo "This is a web server"
fi
Differences: && and || can be used directly inside [[ ]] (inside [ ] they need -a, -o, which are considered deprecated in POSIX); using variables without quotes is also safer (empty-string or space issues are less likely); and == supports pattern matching. [[ ]] is recommended in new, Bash-specific scripts; if a script needs to also run under another shell (sh, dash), the POSIX-compatible [ ] is used instead.
if, if/else, if/elif/else
if starts with a condition, then moves to the next line, and the block ends with fi (if spelled backward).
else for when the condition doesn't hold:
if [ "$MY_SHELL" = "bash" ]; then
echo "You're using the Bash shell"
else
echo "You're not using Bash: $MY_SHELL"
fi
For checking several conditions in sequence, elif (short for "else if"):
if [ "$MY_SHELL" = "bash" ]; then
echo "Bash"
elif [ "$MY_SHELL" = "zsh" ]; then
echo "Zsh"
else
echo "Unknown shell: $MY_SHELL"
fi
case: a Multi-Way Choice
If a single variable needs to be compared against several specific values, case is considerably more compact and readable than a chain of elif:
#!/bin/bash
read -p "Choose a service (nginx/postgresql/redis): " service
case "$service" in
nginx)
systemctl status nginx
;;
postgresql)
systemctl status postgresql
;;
redis)
systemctl status redis
;;
*)
echo "Unknown service: $service"
;;
esac
Each option is a pattern (nginx)), followed by the commands to run, ending with ;; — which marks moving to the next option. *) is the default case, catching anything that matched none of the options; it's recommended to always include it, or an unexpected value is silently ignored by default.
for: Iterating Over a List
A for loop assigns each element of a list to a variable, one at a time, and runs the block that many times:
The list is often stored in a variable:
A real practical example — adding today's date as a prefix to every .jpg filename in the current directory:
*.jpg is a wildcard (pattern); before running, Bash expands it into the list of matching files in the current directory (glob expansion), and for iterates over that list.
Iterating through all script arguments via "$@" was already shown in Variables and Arguments — that's also just an application of this same for construct.
while: Repeating While a Condition Holds
Where for runs until a list is exhausted, while keeps repeating the block for as long as the condition stays true:
$((...)) is arithmetic expansion, used for addition, subtraction, and other operations on integers.
while is also very convenient for reading a file line by line:
Here, < /etc/hosts redirects the file into the while loop's standard input, and read -r reads the next line on each call, continuing the loop until the file ends. The -r flag ensures a backslash (\) is read as an ordinary character, not a special one — this is added almost as a habit, nearly every time.
until is the opposite of while: the block runs for as long as the condition is false. In practice until is used rarely, since writing the condition's negation (! [ condition ]) achieves the exact same result; but in some cases — such as "wait until a service starts" — until expresses the logic more naturally:
until systemctl is-active --quiet nginx; do
echo "nginx hasn't started yet, waiting..."
sleep 1
done
echo "nginx has started"
break and continue: Controlling a Loop Early
A loop doesn't have to run to its natural end. Two keywords change its flow, and they come up constantly in real scripts:
break— leave the loop immediately;continue— skip the rest of this iteration and move to the next one.
#!/bin/bash
# Find the first writable directory from a preference list and stop looking
for dir in /var/log/myapp /tmp/myapp /tmp; do
if [ ! -d "$dir" ]; then
continue # not there — try the next candidate
fi
if [ -w "$dir" ]; then
echo "Using: $dir"
break # found one — no need to check the rest
fi
done
In a nested loop, break N/continue N acts on the N-th enclosing loop (break 2 exits two levels at once) — useful when scanning a grid of hosts × ports and one hit should abandon the whole search.
Arithmetic Loops: (( )) and the C-Style for
The while [ "$count" -le 5 ] counter above works, but Bash has purpose-built arithmetic syntax that reads more naturally for numeric loops. Inside (( )), ordinary math operators (<, >, ==, ++) are used directly, with no -lt/-eq and no $ needed on variable names:
# C-style for — the idiomatic numeric counter
for (( i = 1; i <= 5; i++ )); do
echo "Counting: $i"
done
# the same logic as a while with an arithmetic condition
count=1
while (( count <= 5 )); do
echo "Counting: $count"
(( count++ ))
done
(( )) is for numbers, [[ ]] is for files/strings — a distinction interviewers probe: [ "$a" -lt "$b" ] and (( a < b )) both compare integers, but (( )) is cleaner for arithmetic and, like [[ ]], is a Bash extension (not POSIX sh).
Warning
(( )) returns a non-zero exit status when the expression evaluates to 0. So (( count++ )) when count starts at 0 "fails" — harmless normally, but under set -e (see Exit Codes and Error Handling) it can stop the script unexpectedly. Use (( count++ )) || true, or the pre-increment (( ++count )), when strict mode is on.
Practical Scenario: Checking a Service's State and Acting on the Result
Task: a script that checks whether a given service is running, and tries to restart it if it isn't.
#!/bin/bash
# Usage: ./check_service.sh <service_name>
service="$1"
if [ -z "$service" ]; then
echo "Error: please provide a service name. Usage: $0 <service_name>"
exit 1
fi
if systemctl is-active --quiet "$service"; then
echo "$service is running"
else
echo "$service is not running, restarting..."
sudo systemctl restart "$service"
if systemctl is-active --quiet "$service"; then
echo "$service restarted successfully"
else
echo "$service failed to restart — check the log: journalctl -u $service"
exit 1
fi
fi
Three things come together in this scenario: an argument check (-z), a choice based on state (if/else), and a re-check of the result (a nested if). The exit 1 lines are explained fully in Exit Codes and Error Handling.
Common Mistakes
No Space Between the Brackets and the Expression
[ is itself a separate command, so a space is required both after it and before ]: [ -f /etc/hosts ].
Writing then on the Same Line Without a Semicolon
is correct on two lines, but on a single line a ; is needed:
Using a Variable Without Quotes in a Test
If $1 isn't given at all, this becomes [ -z ], which either errors or returns an unexpected result. Correct: [ -z "$1" ] — quoting correctly passes an empty value too, as a single argument.
Forgetting to Quote in for color in $colors (the Reverse Case)
Unlike "$@", in cases where a list needs to be split into words (for example, for color in $colors), the variable is deliberately left unquoted — otherwise the entire list is read as a single element. This shows why knowing when quoting is needed, and when it isn't, matters: is the goal splitting a list (unquoted) or preserving one whole value (quoted)?
Forgetting the Default Case (*)) in case
If a default case isn't written, any value in the list that matches none of the options is silently ignored by default — raising the risk of an unnoticed mistake.
Exercises
- Write a
check_disk.shscript that reads the usage percentage fromdfoutput (useawkor parameter expansion to extract it as a number), and warns if it's over 80%, or says "enough space" otherwise. - Using
case, write adeploy.shscript that acceptsstart,stop,restart, orstatusvia$1and prints a matching message for each; for an unknown argument, show a usage message. - Write a
count_down.shscript that uses awhileloop to count down from a given number ($1) to 1, waiting 1 second each time (sleep 1), and prints "Done!" at the end. - Read
/etc/passwdline by line withwhile read -r line; do ... done < /etc/passwd, and print the names of only the users whose shell isbash(use pattern matching incase, or[[ "$line" == *bash ]]).
Summary
Tests ([ ], [[ ]]) give a true/false answer about files, strings, and numbers; if/elif/elseandcasemake decisions based on those answers;forandwhile` repeat actions over a list or while a condition holds. Together, these three give a script the ability to make real decisions. The next article, Functions, covers writing this logic once and calling it by name instead of repeating it — a way to reduce code duplication.