Bash Control Flow
Master if/elif/else, case statements, for loops, while and until loops, and break/continue in Bash.
if / elif / else
Control flow lets your scripts make decisions and react differently to different inputs or system states. Without it, every script would be a fixed sequence of commands with no ability to branch or adapt. The if statement is the fundamental building block.
#!/usr/bin/env bash
score=85
if [[ $score -ge 90 ]]; then
echo "Grade: A"
elif [[ $score -ge 80 ]]; then
echo "Grade: B"
elif [[ $score -ge 70 ]]; then
echo "Grade: C"
else
echo "Grade: F"
fi
# Grade: B
The fi closes the if block (it is if spelled backwards). Every if must end with fi.
Testing commands directly
Any command that produces an exit code can be a condition. Exit code 0 means true (success); anything else means false (failure). This means you can use grep, ping, mkdir, or any other command directly in an if without wrapping it in [[ ]].
# Run the body if grep finds a match (exit code 0)
if grep -q "error" /var/log/app.log; then
echo "Errors found in log"
fi
# Run the body if the file exists
if [[ -f "/etc/nginx/nginx.conf" ]]; then
echo "nginx is configured"
fi
# The ! inverts the exit code — run the body if ping fails
if ! ping -c 1 -W 2 google.com &>/dev/null; then
echo "No internet connection"
fi
One-liner if
For simple guard checks, the && and || short-circuit operators are more concise than a full if/fi block.
[[ -d /tmp/work ]] || mkdir /tmp/work # create dir if missing
[[ -f config.yml ]] && source config.yml # source only if file exists
case Statement
case solves the problem of matching one variable against many possible values without writing a long chain of elif blocks. It is especially clean for script subcommands and file type routing.
#!/usr/bin/env bash
action="${1:-help}"
case "$action" in
start)
echo "Starting service..."
;;
stop)
echo "Stopping service..."
;;
restart|reload)
# Multiple patterns share a block with |
echo "Restarting service..."
;;
status)
systemctl status myapp
;;
--help|-h|help)
echo "Usage: $0 {start|stop|restart|status}"
;;
*)
# Default catch-all — runs if nothing else matched
echo "Unknown action: $action" >&2
exit 1
;;
esac
Each pattern ends with ) and each block ends with ;;. The *) is the default catch-all, equivalent to else.
Glob patterns work in case, making it useful for routing by file type:
filename="report_2025.csv"
case "$filename" in
*.csv) echo "CSV file" ;;
*.json) echo "JSON file" ;;
*.log) echo "Log file" ;;
*) echo "Unknown type" ;;
esac
for Loop
Iterating over a list
The for loop processes each item in a list in turn. This is the most common loop pattern in Bash — processing files, servers, arguments, or any collection of values.
for fruit in apple banana cherry; do
echo "I like $fruit"
done
# I like apple
# I like banana
# I like cherry
Iterating over an array
When iterating over an array, always use "${array[@]}" in double quotes. This correctly handles elements that contain spaces and prevents word splitting from breaking your logic.
servers=("web01" "web02" "db01" "cache01")
for server in "${servers[@]}"; do
echo "Checking $server..."
ping -c 1 -W 2 "$server" &>/dev/null && echo " OK" || echo " UNREACHABLE"
done
Iterating over files
Glob patterns expand directly in for loops. Always check that the glob matched at least one file — if no files match, the pattern itself becomes the loop variable.
# Process all .log files in a directory
for logfile in /var/log/nginx/*.log; do
[[ -f "$logfile" ]] || continue # skip if no files matched the glob
echo "Rotating: $logfile"
gzip -9 "$logfile"
done
C-style for loop
When you need a numeric counter or need to iterate a fixed number of times, the C-style for (( )) loop is cleaner than generating a sequence.
for (( i = 1; i <= 5; i++ )); do
echo "Iteration $i"
done
# Count down with a delay
for (( i = 10; i >= 0; i-- )); do
printf "\r%d " "$i"
sleep 1
done
echo "Blast off!"
seq and brace expansion
For simple numeric ranges, brace expansion is the fastest and most readable option:
# Brace expansion — generates 1 2 3 4 5
for i in {1..5}; do
echo "Step $i"
done
# With zero-padding
for i in {01..10}; do
echo "backup_${i}.tar.gz"
done
# Using seq for a step value
for i in $(seq 0 10 100); do # 0, 10, 20, ... 100
echo "$i%"
done
while Loop
while runs as long as its condition is true. It is the right tool when you do not know in advance how many iterations you need — waiting for a service to start, reading lines from a file, or retrying a failing command.
count=1
while [[ $count -le 5 ]]; do
echo "Count: $count"
(( count++ ))
done
Reading a file line by line
Reading files line by line with while and read is the standard pattern in Bash. The IFS= clears the field separator so leading and trailing whitespace is preserved, and -r prevents backslash interpretation.
while IFS= read -r line; do
echo "Processing: $line"
done < /etc/hosts
Reading command output
Process substitution < <(command) feeds command output to a while loop without creating a subshell. This is important because changes to variables made inside a pipe’s subshell are lost after the loop ends.
# Process each running container name
while IFS= read -r container; do
echo "Container: $container"
docker inspect "$container" | jq '.[] | .State.Status'
done < <(docker ps -q)
# Variables updated inside this loop persist after it ends
Retry loop
Combining while with a break and a counter is the standard pattern for waiting on a service or retrying a flaky command with a limit.
max_attempts=5
attempt=1
while [[ $attempt -le $max_attempts ]]; do
echo "Attempt $attempt/$max_attempts..."
if curl -sf "http://localhost:8080/health" &>/dev/null; then
echo "Service is up!"
break
fi
(( attempt++ ))
sleep 5
done
if [[ $attempt -gt $max_attempts ]]; then
echo "Service failed to come up after $max_attempts attempts" >&2
exit 1
fi
until Loop
until is the logical inverse of while — it runs until the condition becomes true. It reads naturally for “wait until something is ready” patterns, though most experienced bash writers just use while with a negated condition.
# Wait for a done-file to appear before proceeding
until [[ -f "/tmp/job.done" ]]; do
echo "Waiting for job to finish..."
sleep 2
done
echo "Job complete!"
break and continue
break and continue give you fine-grained control over loop execution. They are essential for filtering, early termination, and error handling inside loops.
break exits the loop immediately. continue skips the rest of the current iteration and moves to the next one.
# continue — skip even numbers, print only odd
for i in {1..10}; do
(( i % 2 == 0 )) && continue # skip to next iteration
echo "$i"
done
# break — stop at the first unreachable host
for host in web01 web02 web03; do
if ! ssh -o ConnectTimeout=5 "$host" "uptime" &>/dev/null; then
echo "Cannot reach $host — aborting" >&2
break
fi
echo "$host is up"
done
break 2 and continue 2 operate on the enclosing outer loop in nested loops — the number indicates how many levels to break out of:
for i in {1..3}; do
for j in {1..3}; do
if [[ $i -eq 2 && $j -eq 2 ]]; then
break 2 # exits both loops at once
fi
echo "$i,$j"
done
done
# 1,1 1,2 1,3 2,1
select — Interactive Menus
select automatically generates a numbered menu from a list and loops until break is called. It is useful for interactive scripts that need the user to choose from a fixed set of options.
#!/usr/bin/env bash
echo "Choose a deployment environment:"
select env in development staging production quit; do
case "$env" in
development|staging|production)
echo "Deploying to $env..."
break
;;
quit)
echo "Cancelled"
exit 0
;;
*)
echo "Invalid option. Try again."
;;
esac
done
What’s Next
The next tutorial covers functions — how to define reusable blocks of code, pass arguments, use local variables, and manage return codes.