Skip to main content
Bash beginner Lesson 6 of 22

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.

Frequently Asked Questions

Why does my if statement fail with 'command not found'?
The condition in an if statement must be a command that returns an exit code. If you write if $x == 5, bash tries to run the value of $x as a command. Use if [[ $x -eq 5 ]] instead.
What is the difference between for x in list and for (( )) loops?
for x in list iterates over words or array elements. The C-style for (( i=0; i<10; i++ )) loop iterates by incrementing a counter — useful when you need the index.
When should I use while vs until?
while runs as long as the condition is true. until runs as long as the condition is false (i.e., until it becomes true). They are logical inverses. Most people use while exclusively.