Skip to content
DevOps devops shell 5 min read

Loops (for, while, until)

A loop lets your script repeat the same set of commands many times without you writing them out by hand. This is one of the biggest reasons to script at all: instead of restarting ten services one by one, you write the restart once and let a loop run it ten times. In Bash (the Bourne Again SHell, the default command-line shell on Ubuntu) you have three loop types — for, while, and until — and each fits a different job. By the end of this page you will be able to loop over files in a directory, read a log file line by line, and wait for a condition to become true.

The for loop: repeat over a list

A for loop walks through a list of items one at a time. On each pass (called an iteration) it puts the current item into a variable you name, then runs the body of the loop. Use it when you already know the set of things you want to process — a fixed list of words, a glob of files, or a range of numbers.

#!/usr/bin/env bash
for service in nginx postgresql ssh; do
  echo "Checking $service..."
  systemctl is-active "$service"
done

Output:

Checking nginx...
active
Checking postgresql...
active
Checking ssh...
active

The structure is for VARIABLE in LIST; do ... done. systemctl is the tool that controls services (units managed by systemd, the program that starts and supervises everything on a modern Ubuntu server). Here is-active simply prints whether each service is running.

Looping over a range of numbers

To repeat a fixed number of times, use a brace expansion range like {1..5}. Bash expands this to 1 2 3 4 5 before the loop runs.

for i in {1..5}; do
  echo "Attempt $i"
done

You can also add a step: {0..20..5} gives 0 5 10 15 20. For a count whose end is stored in a variable, use the C-style form instead, because brace expansion does not expand variables:

count=3
for ((i = 1; i <= count; i++)); do
  echo "Loop number $i"
done

Avoid the old habit of writing for f in $(ls). Filenames can contain spaces, and ls output will split them into broken pieces. Always loop over a glob (for f in *.log) instead, which Bash splits correctly.

Looping over files in a directory

A glob is a pattern like *.conf that Bash expands into the matching filenames. This is the safe, idiomatic way to process files. Here we check every site config in the Nginx directory (Nginx is a popular web server and reverse proxy — a server that sits in front of your app and forwards requests to it).

#!/usr/bin/env bash
for conf in /etc/nginx/sites-available/*; do
  echo "Found config: $conf"
done

Output:

Found config: /etc/nginx/sites-available/default
Found config: /etc/nginx/sites-available/myapp.conf

One gotcha: if no files match, the glob stays as the literal text *. Guard against that by checking the path exists with [ -e "$conf" ], or enable shopt -s nullglob at the top of your script so an unmatched glob expands to nothing.

The while loop: repeat as long as a condition holds

A while loop keeps running its body while a test command succeeds (returns exit code 0, the convention for “success” on Linux). Use it when you do not know in advance how many iterations you need — such as polling until a server comes up, or reading until a file ends.

#!/usr/bin/env bash
attempts=0
while ! curl -fsS http://localhost:8080/health > /dev/null; do
  attempts=$((attempts + 1))
  echo "Waiting for app... (try $attempts)"
  sleep 2
done
echo "App is up after $attempts tries"

curl is a command-line tool for making HTTP requests. The ! flips the result, so the loop continues while the health check is failing. sleep 2 pauses two seconds between checks so you do not hammer the server.

Reading a file line by line: the while read -r pattern

The single most common while use in DevOps is reading a file one line at a time. The idiom is while IFS= read -r line; do ... done < file:

#!/usr/bin/env bash
while IFS= read -r line; do
  echo "LOG: $line"
done < /var/log/nginx/access.log

Output:

LOG: 203.0.113.4 - - [15/Jun/2026:09:01:22] "GET / HTTP/1.1" 200
LOG: 203.0.113.7 - - [15/Jun/2026:09:01:25] "GET /api HTTP/1.1" 404

Two details make this correct and worth memorising:

  • read -r tells read not to treat backslashes as escape characters, so paths and log data come through exactly as written.
  • IFS= (clearing the Internal Field Separator) stops read from trimming leading and trailing whitespace, preserving each line as-is.

The < /var/log/... part redirects the file into the loop’s input. You can also pipe a command in — for example, process the output of journalctl (the tool that reads systemd’s logs):

journalctl -u nginx --no-pager | while IFS= read -r line; do
  echo "$line" | grep -i error
done

The until loop: repeat until a condition becomes true

until is the mirror image of while: it runs until its test succeeds, meaning it loops while the test is failing. Some people find it reads more naturally for “wait for X” tasks.

#!/usr/bin/env bash
until systemctl is-active --quiet postgresql; do
  echo "PostgreSQL not ready yet, waiting..."
  sleep 1
done
echo "PostgreSQL is active"

--quiet suppresses the printed status so only the exit code is used. This loop exits the moment PostgreSQL (a relational database) reports active.

When to use which

LoopBest forStops when
forA known list, glob, or rangeThe list is exhausted
whileReading files, polling, unknown countThe test command fails
untilWaiting for a condition to come trueThe test command succeeds

Best Practices

  • Always quote your loop variable ("$file") so filenames with spaces do not break.
  • Use globs (*.log) instead of parsing ls output to iterate over files.
  • Use while IFS= read -r line for reading files — it preserves whitespace and backslashes correctly.
  • Add sleep inside polling while/until loops so you do not spin the CPU at 100 percent.
  • Cap retry loops with a counter and a maximum so a script cannot hang forever waiting on a dead service.
  • Use break to exit a loop early and continue to skip to the next iteration when a condition is met.
  • Enable shopt -s nullglob when a glob might match nothing, so the loop body does not run on a literal *.
Last updated June 15, 2026
Was this helpful?