Skip to content
elephantoo

Conditionals & loops in bash

Lesson 24 of 31 17 min read

if, test and [[ ]], file tests, case, for, while and until loops, and reading files line by line.


Scripts become truly useful when they can decide ("if the backup failed, alert me") and repeat ("for every log file, compress it"). In this lesson you'll learn if, the test commands [ ] and [[ ]], case, and the for, while and until loops, including the right way to process files line by line.

if: decisions based on exit codes#

In bash, if doesn't evaluate a boolean expression. It runs a command and checks its exit status: 0 means true, anything else false.

Terminal
printf "alice\nbob\n" > users.txt
if grep -q bob users.txt; then
  echo "bob is a user"
else
  echo "bob not found"
fi
if mkdir -p reports; then echo "reports/ is ready"; fi
Output
bob is a user
reports/ is ready

So any command can be a condition. Full syntax: if ...; then ...; elif ...; then ...; else ...; fi.

Tests: [ ] and [[ ]]#

To compare values or check files, use a test command:

  • [ ... ] is the classic POSIX test command. It works in every shell, including sh/dash.
  • [[ ... ]] is bash's improved version: safer with empty and unquoted variables, and it supports &&, ||, pattern matching and regexes. Prefer it in bash scripts.

The spaces inside the brackets are required: [[ $x == y ]], not [[$x==y]].

String tests

Terminal
name="ada"; empty=""
[[ $name == "ada" ]] && echo "equal"
[[ $name != "bob" ]] && echo "not bob"
[[ -z $empty ]] && echo "empty is empty"
[[ -n $name ]] && echo "name is not empty"
[[ $name == a* ]] && echo "starts with a (glob pattern)"
[[ "report-2026.csv" =~ ^report-([0-9]{4})\.csv$ ]] && echo "year: ${BASH_REMATCH[1]}"
Output
equal
not bob
empty is empty
name is not empty
starts with a (glob pattern)
year: 2026

Number tests

Terminal
count=12
[[ $count -gt 10 ]] && echo "more than 10"
[[ $count -eq 12 ]] && echo "exactly 12"
(( count % 2 == 0 )) && echo "even (arithmetic test)"
[[ 10 < 9 ]] && echo "careful: '10' < '9' as STRINGS"
Output
more than 10
exactly 12
even (arithmetic test)
careful: '10' < '9' as STRINGS
IntegersMeaningArithmetic (( ))
-eqequal==
-nenot equal!=
-lt / -leless than / or equal< / <=
-gt / -gegreater than / or equal> / >=

File tests

Terminal
touch notes.txt; mkdir -p data
for p in notes.txt data missing; do
  if [[ -d $p ]]; then echo "$p: directory"
  elif [[ -f $p ]]; then echo "$p: regular file (size>0? $([[ -s $p ]] && echo yes || echo no))"
  else echo "$p: does not exist"
  fi
done
Output
notes.txt: regular file (size>0? no)
data: directory
missing: does not exist
TestTrue if
-e pathexists (any type)
-f pathis a regular file
-d pathis a directory
-L pathis a symlink
-s pathexists and is not empty
-r / -w / -x pathreadable / writable / executable by you
a -nt ba is newer than b

Combining conditions

Terminal
age=25; country="IN"
if [[ $age -ge 18 && $country == "IN" ]]; then echo "eligible"; fi
if [[ ! -f config.yml ]]; then echo "config.yml missing"; fi
[[ -f notes.txt ]] || echo "this won't print"
Output
eligible
config.yml missing

Inside [[ ]] use &&, || and !. With the old [ ], chain separate tests instead: [ "$a" -gt 1 ] && [ "$b" = x ] (and always quote variables inside [ ]).

case: matching patterns#

case is cleaner than long if/elif chains when you're matching one value against patterns:

Terminal
cat > service.sh <<'EOF'
#!/usr/bin/env bash
case "$1" in
  start)          echo "Starting..." ;;
  stop)           echo "Stopping..." ;;
  restart|reload) echo "Restarting..." ;;
  *.conf)         echo "Loading config file $1" ;;
  "")             echo "Usage: $0 {start|stop|restart|FILE.conf}" >&2; exit 2 ;;
  *)              echo "Unknown command: $1" >&2; exit 1 ;;
esac
EOF
chmod +x service.sh
./service.sh start; ./service.sh reload; ./service.sh app.conf; ./service.sh fly
Output
Starting...
Restarting...
Loading config file app.conf
Unknown command: fly

Each branch ends with ;;. Patterns use glob syntax (*, ?, [abc]), and | means "or". *) is the catch-all.

for loops#

Over a list of words:

Terminal
for fruit in apple banana cherry; do
  echo "I like $fruit"
done
Output
I like apple
I like banana
I like cherry

Over files. Use a glob, never $(ls):

Terminal
touch "jan report.csv" feb.csv mar.csv
for f in *.csv; do
  echo "Processing: $f"
done
Output
Processing: feb.csv
Processing: jan report.csv
Processing: mar.csv

Quoting "$f" inside the loop body keeps names with spaces intact. If nothing matches, the glob stays as the literal text *.csv; shopt -s nullglob makes it expand to nothing instead.

Over a range of numbers, or C-style:

Terminal
for i in {1..5}; do printf "%s " "$i"; done; echo
for i in {0..20..5}; do printf "%s " "$i"; done; echo
for ((i = 3; i > 0; i--)); do echo "T-minus $i"; done
Output
1 2 3 4 5
0 5 10 15 20
T-minus 3
T-minus 2
T-minus 1

(Brace ranges can't use variables: {1..$n} doesn't work. Use for ((i=1; i<=n; i++)) or $(seq 1 "$n").)

Over arguments: for arg in "$@"; do ...; done, or just for arg; do ...; done.

while and until#

while repeats as long as a command succeeds; until repeats until it succeeds:

Terminal
n=1
while (( n <= 3 )); do
  echo "attempt $n"
  ((n++))
done
tries=0
until [[ -f ready.flag ]]; do
  ((tries++))
  (( tries == 2 )) && touch ready.flag   # simulate something finishing
done
echo "ready after $tries checks"
Output
attempt 1
attempt 2
attempt 3
ready after 2 checks

A realistic until waits for a service: until curl -fs http://localhost:8080/health; do sleep 2; done. Add a maximum number of tries so it can't loop forever.

Reading a file line by line#

The correct idiom is while IFS= read -r:

Terminal
cat > servers.txt <<'EOF'
web1 10.0.0.11 nginx
db1  10.0.0.21 mysql
# comment line
cache1 10.0.0.31 redis
EOF
while read -r host ip role; do
  [[ -z $host || $host == \#* ]] && continue   # skip blanks and comments
  echo "$host ($role) is at $ip"
done < servers.txt
Output
web1 (nginx) is at 10.0.0.11
db1 (mysql) is at 10.0.0.21
cache1 (redis) is at 10.0.0.31
  • read -r host ip role splits each line on whitespace into fields; the last variable gets the rest of the line.
  • Use while IFS= read -r line to keep the whole line exactly, including leading spaces.
  • < servers.txt goes after done.
  • Avoid for line in $(cat file): it splits on every space, not on lines.

Piping into while (cmd | while read ...) runs the loop in a subshell, so variables set inside are lost afterwards. Use done < <(cmd) (process substitution) when you need them:

Terminal
total=0
while read -r size; do
  ((total += size))
done < <(printf "100\n250\n50\n")
echo "total=$total"
Output
total=400

break and continue#

Terminal
for n in 1 2 3 4 5 6; do
  (( n == 2 )) && continue    # skip to the next iteration
  (( n == 5 )) && break       # leave the loop
  echo "n=$n"
done
Output
n=1
n=3
n=4

Putting it together#

A disk-space checker that warns about any filesystem above a threshold:

Terminal
cat > disk-alert.sh <<'EOF'
#!/usr/bin/env bash
threshold="${1:-80}"
status=0
while read -r use mount; do
  use="${use%\%}"                         # strip the % sign
  if (( use >= threshold )); then
    echo "WARNING: $mount is ${use}% full" >&2
    status=1
  fi
done < <(df --output=pcent,target -x tmpfs -x devtmpfs | tail -n +2)
(( status == 0 )) && echo "All filesystems below ${threshold}%"
exit "$status"
EOF
chmod +x disk-alert.sh
./disk-alert.sh 101; echo "exit: $?"
./disk-alert.sh 0 2>&1 | head -2; echo "exit: ${PIPESTATUS[0]}"
Output
All filesystems below 101%
exit: 0
WARNING: / is 17% full
exit: 1

Run it from cron and you get an alert whenever a disk crosses the line. (Your mount points will differ.)

Common mistakes#

  • Missing spaces in tests: [[$a == $b]] and if [ $a=$b ] are both wrong.
  • Using = vs -eq wrongly: -eq is for integers, and == (or =) for strings.
  • > and < inside [[ ]] compare strings, not numbers; inside [ ] they are redirections and create files!
  • Unquoted variables in [ ]: [ $x = y ] errors when $x is empty. Use [[ ]] or quote it.
  • for f in $(ls) or for line in $(cat file) break on spaces. Use globs and while read.
  • Infinite while/until loops with no timeout or retry limit.

What's next#

Your scripts are getting longer, so next you'll organise them with functions, and make them fail safely with set -euo pipefail, trap and argument parsing.

Check your understanding

Quick quiz

0/3 answered
  1. 1.Inside [[ ]], how do you compare two numbers for 'greater than'?

  2. 2.What is the safe way to read a file line by line in bash?

  3. 3.Which test is true if a path exists and is a directory?

Finished reading?

Mark this lesson complete to track your progress.