Skip to content

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/else constructs;
  • write a more compact multi-way choice with case;
  • write a for loop over list elements and a while loop 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):

[ -e /etc/passwd ]

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.

help test    # built-in help for the test command
man test     # full documentation

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
if [ -f /etc/nginx/nginx.conf ]; then
    echo "Configuration file found"
fi

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
FREE_PERCENT=12
if [ "$FREE_PERCENT" -lt 15 ]; then
    echo "Disk space is running low"
fi

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

#!/bin/bash

MY_SHELL="bash"

if [ "$MY_SHELL" = "bash" ]; then
    echo "You're using the Bash shell"
fi

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:

#!/bin/bash

for color in red green blue; do
    echo "$color"
done
red
green
blue

The list is often stored in a variable:

colors="red green blue"

for color in $colors; do
    echo "$color"
done

A real practical example — adding today's date as a prefix to every .jpg filename in the current directory:

#!/bin/bash

today=$(date +%Y-%m-%d)

for file in *.jpg; do
    mv "$file" "${today}-${file}"
done
bear.jpg   ->  2026-07-26-bear.jpg
cat.jpg    ->  2026-07-26-cat.jpg

*.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:

#!/bin/bash

count=1

while [ "$count" -le 5 ]; do
    echo "Counting: $count"
    count=$((count + 1))
done
Counting: 1
Counting: 2
Counting: 3
Counting: 4
Counting: 5

$((...)) is arithmetic expansion, used for addition, subtraction, and other operations on integers.

while is also very convenient for reading a file line by line:

#!/bin/bash

while read -r line; do
    echo "Line: $line"
done < /etc/hosts

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
./check_service.sh nginx
nginx is running

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

if [-f /etc/hosts]; then
bash: [-f: command not found

[ 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

if [ -f /etc/hosts ]
then

is correct on two lines, but on a single line a ; is needed:

if [ -f /etc/hosts ]; then

Using a Variable Without Quotes in a Test

if [ -z $1 ]; then

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

  1. Write a check_disk.sh script that reads the usage percentage from df output (use awk or parameter expansion to extract it as a number), and warns if it's over 80%, or says "enough space" otherwise.
  2. Using case, write a deploy.sh script that accepts start, stop, restart, or status via $1 and prints a matching message for each; for an unknown argument, show a usage message.
  3. Write a count_down.sh script that uses a while loop to count down from a given number ($1) to 1, waiting 1 second each time (sleep 1), and prints "Done!" at the end.
  4. Read /etc/passwd line by line with while read -r line; do ... done < /etc/passwd, and print the names of only the users whose shell is bash (use pattern matching in case, 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.

References