Mert Özen Further With Every Line
DevOps Bash Scripting — #03

Conditionals and Loops: if, [[ ]], case, for, while read and Looping Over Files Safely

Mert Özen Nov 15, 2026 15 min 5 views
Conditionals and Loops: if, [[ ]], case, for, while read and Looping Over Files Safely

In Bash, if looks at a command's exit code rather than a condition; bash scripts use [[ ]], numbers use (( )), reading line by line uses while IFS= read -r, and file names use a glob or find -print0. While writing a bookstore-ctl.sh with subcommands and a cleanup script that keeps the last five releases on the Bookstore server, we show how glob sorting can delete the live release, the file created by > inside [ ] and the counter lost by a while loop in a pipe.

Version note: The examples in this post were run on Ubuntu 24.04 LTS with Bash 5.2.21 and Bash 5.3.20 built from source, using coreutils 9.4, findutils 4.9 and ShellCheck 0.11.0.

Guess First

On the Bookstore server, every deploy creates a new folder named 2026-11-14-N under /opt/bookstore/releases, and the current symlink points to the newest one. There have been 11 deploys today:

$ for d in releases/*/; do printf '%s ' "${d#releases/}"; done; echo
2026-11-14-1/ 2026-11-14-10/ 2026-11-14-11/ 2026-11-14-2/ 2026-11-14-3/ 2026-11-14-4/ 2026-11-14-5/ 2026-11-14-6/ 2026-11-14-7/ 2026-11-14-8/ 2026-11-14-9/
$ readlink current
/opt/bookstore/releases/2026-11-14-11
$ releases=(releases/*/); echo "${#releases[@]} releases"
11 releases

To keep the disk from filling up you want to delete everything except the last five, and you take the folders to delete as the first "total minus five" elements of the array: "${releases[@]:0:${#releases[@]}-5}". Which folders do you think this list contains? The answer is in the for section; you will be glad you looked at the list before deleting it.

In the previous post we learned to use variables safely. In this post we make decisions with them: if and test commands, the difference between [ ], [[ ]] and (( )), regex matching, subcommands with case, for and while read loops, and looping over files safely. By the end we have a real script that cleans up old releases and a single entry point for tasks on the Bookstore server.

if, test and Exit Codes

if does not look at a condition, it looks at a command: if the command ends with 0, then runs, otherwise else. The square bracket is actually a command too:

$ type [ [[ test
[ is a shell builtin
[[ is a shell keyword
test is a shell builtin
$ if grep -q '^release 11$' /opt/bookstore/current/VERSION; then echo "11 is live"; fi
11 is live

[ is another name for the test command, and its last argument must be ]. So everything inside it is an ordinary argument and goes through word splitting and globbing. [[ is a bash keyword; bash parses its contents itself, does not split variables and does not expand globs. You can use any command, such as grep -q, directly as a condition; there is no need to write if [ $(grep -c ...) -gt 0 ]. The difference is easy to see with an empty variable:

$ env_name=""
$ [ $env_name = production ] && echo "prod"
bash: [: =: unary operator expected
$ [[ $env_name = production ]] || echo "not prod"
not prod
$ [ "$env_name" = production ] || echo "not prod"
not prod

When the unquoted $env_name is empty, the [ command only sees = production ] and fails. The same expression works inside [[ ]]; if you use [ ], quotes are mandatory. In this series we use [[ ]] in bash scripts; in #!/bin/sh scripts only [ ] exists.

The third form, (( )), is for numbers. The comparison operators change meaning here:

$ version=10; [ "$version" > 9 ] && echo "bigger?"; ls
bigger?
9
$ [[ 10 < 9 ]] && echo "string compare: 10 < 9"
string compare: 10 < 9
$ (( 10 < 9 )) || echo "arithmetic: 10 is not < 9"
arithmetic: 10 is not < 9
$ [ 10 -lt 9 ] || echo "test -lt: 10 is not < 9"
test -lt: 10 is not < 9

Inside [ ], > is a redirection: no test happened, an empty file named 9 appeared in the folder and "bigger?" was printed anyway. Inside [[ ]], < and > compare strings, and "10" comes before "9" alphabetically. Compare numbers with (( )) or with -lt, -gt, -eq.

String, File and Regex Tests

The tests you will use most in scripts:

$ cur=/opt/bookstore/current
$ [[ -L $cur ]] && echo "symlink"; [[ -d $cur ]] && echo "points to a directory"; [[ -f $cur ]] || echo "not a regular file"
symlink
points to a directory
not a regular file
$ [[ -s $cur/VERSION ]] && echo "VERSION is not empty"
VERSION is not empty
$ [[ -x /usr/bin/curl ]] && echo "curl is executable"
curl is executable
$ [[ -e /etc/bookstore/secrets.env ]] || echo "no secrets file"
no secrets file
$ [[ -z $UNSET_VAR && -n $HOME ]] && echo "-z empty, -n not empty"
-z empty, -n not empty
$ f="Bookstore.Api.log"; [[ $f == *.log ]] && echo "log file"
log file
$ [[ $f == "*.log" ]] || echo "quoted pattern is literal"
quoted pattern is literal

-e exists, -f regular file, -d directory, -L symlink, -s non-empty file, -r/-w/-x readable/writable/executable; -z empty string, -n non-empty string. On a symlink, -d and -f look at the target, -L at the link itself. Inside [[ ]] the right-hand side of == is a glob pattern when unquoted and plain text when quoted; && and || can be used directly inside as well.

=~ matches against the extended regex on the right and puts the parenthesised groups into the BASH_REMATCH array:

$ re='^([0-9]+)\.([0-9]+)\.([0-9]+)$'
$ v=1.4.2; [[ $v =~ $re ]] && echo "major=${BASH_REMATCH[1]} minor=${BASH_REMATCH[2]} patch=${BASH_REMATCH[3]}"
major=1 minor=4 patch=2
$ v=1.4; [[ $v =~ $re ]] || echo "not a version: $v"
not a version: 1.4
$ [[ 1.4.2 =~ "^[0-9]+\.[0-9]+" ]] || echo "quoted regex is matched literally"
quoted regex is matched literally

Putting the regex in a variable and using it unquoted is the safest way: if you quote the regex, bash searches for it as plain text. In the previous post we saw HEALTH_TIMEOUT=abc reported as "the API is unhealthy". The health script now gets this check, and a wrong configuration ends with 2, the usage error code:

    local timeout="${HEALTH_TIMEOUT:-5}"
    if [[ ! $timeout =~ ^[1-9][0-9]*$ ]]; then
        echo "HEALTH_TIMEOUT must be a positive integer, got: $timeout" >&2
        exit 2
    fi
$ HEALTH_TIMEOUT=abc ./scripts/bookstore-health.sh; echo "exit=$?"
HEALTH_TIMEOUT must be a positive integer, got: abc
exit=2

Subcommands with case

The server has bookstore-health.sh, the cleanup script we write shortly, and later deploy and rollback. Instead of remembering each of them separately, let's use a single command with subcommands, like git or systemctl. case compares a value against patterns in order and runs the first branch that matches:

#!/usr/bin/env bash
#
# bookstore-ctl.sh - single entry point for everyday tasks on a Bookstore server.
#
# Usage:      bookstore-ctl.sh COMMAND [ARGS...]
# Commands:   status              current release and health check
#             releases            list releases, newest last
#             prune [-n] [KEEP]   delete old releases (see bookstore-prune-releases.sh)
# Variables:  BOOKSTORE_ROOT  installation root (default /opt/bookstore)
# Exit codes: 0 success, 1 error, 2 usage error

SCRIPT_DIR=$(dirname "$(readlink -f "$0")")
ROOT="${BOOKSTORE_ROOT:-/opt/bookstore}"

usage() {
    echo "Usage: ${0##*/} status|releases|prune [ARGS...]" >&2
}

cmd_status() {
    local current
    current=$(readlink -f "$ROOT/current")
    if [[ -z $current || ! -d $current ]]; then
        echo "No current release under $ROOT" >&2
        return 1
    fi
    echo "current release: ${current##*/}"
    "$SCRIPT_DIR/bookstore-health.sh" "$@"
}

cmd_releases() {
    local current name
    current=$(readlink -f "$ROOT/current")
    shopt -s nullglob
    for name in "$ROOT"/releases/*/; do
        name=${name%/}
        echo "${name##*/}"
    done | sort -V | while IFS= read -r name; do
        if [[ $ROOT/releases/$name == "$current" ]]; then
            echo "* $name"
        else
            echo "  $name"
        fi
    done
}

main() {
    local command="${1:-}"
    if (( $# > 0 )); then
        shift
    fi
    case "$command" in
        status)
            cmd_status "$@"
            ;;
        releases)
            cmd_releases
            ;;
        prune)
            "$SCRIPT_DIR/bookstore-prune-releases.sh" "$@"
            ;;
        -h | --help | help)
            usage
            ;;
        "")
            usage
            exit 2
            ;;
        *)
            echo "Unknown command: $command" >&2
            usage
            exit 2
            ;;
    esac
}

main "$@"; exit
$ ./scripts/bookstore-ctl.sh status
current release: 2026-11-14-11
OK http://127.0.0.1:5000/health
$ ./scripts/bookstore-ctl.sh restart; echo "exit=$?"
Unknown command: restart
Usage: bookstore-ctl.sh status|releases|prune [ARGS...]
exit=2

Each branch ends with ;;; | ties several patterns to one branch, and * at the end is the "none of the above" branch. Patterns follow glob rules, so you can also write branches such as *.tar.gz) or [0-9]*). It matters that an unknown command ends with 2 and not 0; a mistyped subcommand in a CI step should not pass silently as "success". SCRIPT_DIR is the folder the script lives in: we call the other scripts relative to it rather than through the PATH, so the bookstore-ops folder works wherever it is copied. The deploy and rollback branches are added in later posts.

for, Globs and nullglob

The answer to Guess First:

$ printf '%s\n' "${releases[@]:0:${#releases[@]}-5}"
releases/2026-11-14-1/
releases/2026-11-14-10/
releases/2026-11-14-11/
releases/2026-11-14-2/
releases/2026-11-14-3/
releases/2026-11-14-4/
$ printf '%s\n' "${releases[@]}" | sort -V | tail -n 3
releases/2026-11-14-9/
releases/2026-11-14-10/
releases/2026-11-14-11/

The list contains the two newest releases, including 11, the one current points to. Globs sort alphabetically, not numerically, and "10" and "11" come before "2". A script that works fine for ten deploys deletes the live release after the tenth. sort -V (version sort) compares numbers as numbers. The GLOBSORT variable that came with Bash 5.3 can change how globs are sorted, but its numeric option only helps with names made entirely of digits; when I tried it on our names the order did not change.

for loops over a list of words; the list can be a glob, a brace expansion, an array or command output:

$ for f in logs/*.txt; do echo "found: $f"; done
found: logs/*.txt
$ shopt -s nullglob; for f in logs/*.txt; do echo "found: $f"; done; echo "loop done"
loop done
$ shopt -u nullglob
$ echo {1..5} {01..03} {a,b}.log
1 2 3 4 5 01 02 03 a.log b.log
$ n=3; for i in $(seq 1 "$n"); do printf '%s ' "$i"; done; echo
1 2 3
$ for (( i = 1; i <= n; i++ )); do printf '%s ' "$i"; done; echo
1 2 3
$ for i in {1..10}; do (( i % 2 == 0 )) && continue; (( i > 7 )) && break; printf '%s ' "$i"; done; echo
1 3 5 7

If no file matches, bash leaves the glob as it is and the loop runs once with a non-existent "file" called logs/*.txt. shopt -s nullglob turns a glob without matches into an empty list; turn it on in every script that loops over files (but be careful with a command such as ls *.txt, where an empty list can mean "all files"). As we saw in the previous post, {1..$n} does not work; when the limit is in a variable, use seq or a C-style for (( )). continue moves on to the next round, break leaves the loop.

while read: Reading Line by Line

The right pattern for processing a file or a command's output line by line is while IFS= read -r line. Every part of it has a reason:

$ cat deploy.env; echo
# Bookstore deploy settings
BOOKSTORE_ENV=staging

  HEALTH_TIMEOUT=10
SHARE_PATH=\\fileserver\bookstore\releases
$ while read line; do echo "[$line]"; done < deploy.env
[# Bookstore deploy settings]
[BOOKSTORE_ENV=staging]
[]
[HEALTH_TIMEOUT=10]
$ while IFS= read -r line || [[ -n $line ]]; do echo "[$line]"; done < deploy.env
[# Bookstore deploy settings]
[BOOKSTORE_ENV=staging]
[]
[  HEALTH_TIMEOUT=10]
[SHARE_PATH=\\fileserver\bookstore\releases]
$ printf '%s\n' 'SHARE_PATH=\\fileserver\bookstore\releases' | while read line; do echo "[$line]"; done
[SHARE_PATH=\fileserverbookstorereleases]

IFS= keeps the spaces at the start and end of the line from being stripped. -r switches off the interpretation of the backslash as an escape character; without it every backslash in the Windows path disappears. If the last line of the file has no line ending, read reads it but returns 1 and the loop ends without processing it; this is common in files edited on Windows. || [[ -n $line ]] rescues that last line too. You can also set IFS to a character and split the line into fields; combined with case, you get a small config reader:

$ while IFS='=' read -r key value || [[ -n $key ]]; do case $key in "" | "#"*) continue ;; BOOKSTORE_ENV | HEALTH_TIMEOUT | SHARE_PATH) echo "ok: $key=$value" ;; *) echo "unknown key: [$key]" >&2 ;; esac; done < deploy.env
ok: BOOKSTORE_ENV=staging
unknown key: [  HEALTH_TIMEOUT]
ok: SHARE_PATH=\\fileserver\bookstore\releases

Catching the indented line as an unknown key is a good thing: because IFS='=' does not trim spaces, the two leading spaces broke the match, but the script did not swallow it silently; it wrote it to stderr.

For file names, the line ending is not a safe separator, because on Linux a file name can even contain a newline. The safe separator is the NUL character: find -print0 separates names with NUL and read -d '' reads up to NUL. Let's compress Bookstore's logs that are older than a week; one file has a space and parentheses in its name:

$ for f in $(find logs -name '*.log' -mtime +7); do echo "[$f]"; done
[logs/bookstore-2026-11-02]
[(copy).log]
[logs/bookstore-2026-11-01.log]
$ find logs -name '*.log' -mtime +7 -print0 | while IFS= read -r -d '' f; do gzip -- "$f" && echo "compressed $f"; done
compressed logs/bookstore-2026-11-02 (copy).log
compressed logs/bookstore-2026-11-01.log
$ ls logs
bookstore-2026-11-01.log.gz
bookstore-2026-11-02 (copy).log.gz
bookstore-2026-11-14.log

A for over $(find ...) or $(ls) is exactly the word splitting from the previous post: the name with a space was split in two. The -- in gzip -- means "end of options"; it keeps a file name that starts with a dash from being taken as an option.

Cleaning Up Old Releases

Let's put the pieces together. The script finds the last N releases with sort -V, deletes the rest and never touches the release that current points to; after a rollback, current may point to an old release. -n only prints what would be deleted:

#!/usr/bin/env bash
#
# bookstore-prune-releases.sh - deletes old Bookstore releases, keeping the newest ones.
#
# Usage:      bookstore-prune-releases.sh [-n] [KEEP]
#             -n    dry run: only print what would be deleted
#             KEEP  number of releases to keep (default 5)
# Variables:  BOOKSTORE_ROOT  installation root (default /opt/bookstore)
# Exit codes: 0 success, 1 error, 2 usage error

usage() {
    echo "Usage: ${0##*/} [-n] [KEEP]" >&2
}

main() {
    local dry_run=0
    if [[ ${1:-} == "-n" ]]; then
        dry_run=1
        shift
    fi
    if (( $# > 1 )); then
        usage
        exit 2
    fi
    local keep="${1:-5}"
    if [[ ! $keep =~ ^[1-9][0-9]*$ ]]; then
        echo "KEEP must be a positive integer, got: $keep" >&2
        exit 2
    fi

    local root="${BOOKSTORE_ROOT:-/opt/bookstore}"
    local releases_dir="$root/releases"
    if [[ ! -d $releases_dir ]]; then
        echo "Releases directory not found: $releases_dir" >&2
        exit 1
    fi
    local current
    current=$(readlink -f "$root/current")

    shopt -s nullglob
    local dirs=("$releases_dir"/*/)
    if (( ${#dirs[@]} <= keep )); then
        echo "Nothing to prune: ${#dirs[@]} releases, keeping $keep"
        return 0
    fi

    local name dir
    # Newest first with a version sort, so 2026-11-14-10 comes after 2026-11-14-9.
    while IFS= read -r name; do
        dir="$releases_dir/$name"
        if [[ $dir == "$current" ]]; then
            echo "skip $name (current)"
            continue
        fi
        if (( dry_run )); then
            echo "would delete $name"
        else
            rm -rf -- "$dir"
            echo "deleted $name"
        fi
    done < <(for dir in "${dirs[@]}"; do dir=${dir%/}; echo "${dir##*/}"; done | sort -V -r | tail -n +"$((keep + 1))")
}

main "$@"; exit

The trailing / in the glob matches directories only. The < <(...) feeding the loop is process substitution; it lets you read a command's output like a file, and we see why we use it instead of a pipe in the fifth post. sort -V -r puts the newest first, and tail -n +6 gives the lines from the sixth onwards, the ones to delete. First a dry run, after a rollback where current points to 4:

$ ln -sfn /opt/bookstore/releases/2026-11-14-4 /opt/bookstore/current
$ ./scripts/bookstore-prune-releases.sh -n 3
would delete 2026-11-14-8
would delete 2026-11-14-7
would delete 2026-11-14-6
would delete 2026-11-14-5
skip 2026-11-14-4 (current)
would delete 2026-11-14-3
would delete 2026-11-14-2
would delete 2026-11-14-1
$ ln -sfn /opt/bookstore/releases/2026-11-14-11 /opt/bookstore/current
$ ./scripts/bookstore-prune-releases.sh
deleted 2026-11-14-6
deleted 2026-11-14-5
deleted 2026-11-14-4
deleted 2026-11-14-3
deleted 2026-11-14-2
deleted 2026-11-14-1
$ ./scripts/bookstore-ctl.sh releases
  2026-11-14-7
  2026-11-14-8
  2026-11-14-9
  2026-11-14-10
* 2026-11-14-11
$ ./scripts/bookstore-prune-releases.sh
Nothing to prune: 5 releases, keeping 5

It matters that the second run deletes nothing: you can run the script from cron every night and running it again does no harm. This property is called idempotency, and we come back to it in the sixth post.

In Production

Give every script that deletes things a dry run, and run that first on a new server. A step where the list to be deleted appears on screen catches a sorting mistake like the one in Guess First before the live release is deleted. Keep the delete conditions narrow: only folders under releases, only the surplus defined by a count, never current. The $dir in rm -rf -- "$dir" always starts with a known root; if you write a delete command that takes the root from the user, add the ${var:?} guard from the previous post.

Choose release names that sort well. When the "N" part of a name such as 2026-11-14-7 goes past 10, alphabetical order breaks; sort -V fixes it, but a fixed-width name such as 20261114-0007 or 20261114T153000Z makes every tool (globs, ls, a file manager) see the same order. Do not tie the order to modification times (ls -t): touching a release folder later changes its place.

Watch the number of external commands in loops. Calling $(...), sed or basename on every round goes unnoticed for a few files and takes minutes over tens of thousands of log lines. Use parameter expansion inside the loop and move bulk work out of the loop into a single awk or sort call; these tools for getting answers out of logs are covered in the text processing post of the Linux and the Shell series.

Common Pitfalls

A counter in a while loop fed by a pipe. A variable incremented inside the loop looks like zero after the loop:

$ count=0; find logs -name '*.log' -mtime +7 -print0 | while IFS= read -r -d '' f; do echo "[$f]"; (( count++ )); done; echo "count=$count"
[logs/bookstore-2026-11-02 (copy).log]
[logs/bookstore-2026-11-01.log]
count=0

Every part of a pipeline runs in its own subshell; count grows in that subshell and disappears with it. That is why the cleanup script uses < <(...) instead of a pipe; the pipe in bookstore-ctl.sh releases is fine because nothing after the loop needs a variable from it. We look at the reason and the other solutions (lastpipe, process substitution) in detail in the fifth post.

Taking a && b || c for if-then-else. c runs not only when a fails but also when b fails:

$ [[ -d /opt/bookstore/current ]] && echo "current exists" || echo "no current release"
current exists
$ [[ -d /opt/bookstore/current ]] && grep -q 'release 99' /opt/bookstore/current/VERSION || echo "no current release"
no current release

In the second line the folder exists, but because grep failed, "no current release" was printed; the message is wrong. Unless b is an echo that always succeeds, write an if.

< and > inside [ ]. As we saw above, these are redirections: [ "$version" > 9 ] does not compare anything, it creates a file named 9 and always succeeds. ShellCheck catches this with SC2071 and suggests -gt.

for over $(ls) or $(find). It splits file names with spaces, behaves oddly when nothing matches, and ShellCheck warns with SC2045. Use a glob or find -print0 | while IFS= read -r -d ''.

A Small Experiment

★☆☆ Add a version subcommand to bookstore-ctl.sh: it reads the VERSION file of the current release and returns 1 with a clear message if the file is missing or empty (-s). Check that the unknown-command branch still returns 2.

★★☆ Add a second condition to the cleanup script: even when there are more than KEEP releases, do not delete the ones newer than 2 days. You can find folder ages with find "$releases_dir" -mindepth 1 -maxdepth 1 -type d -mtime -2. Try it with a dry run and with folders whose dates you changed with touch -d.

★★★ Turn the deploy.env reader into a function: trim leading and trailing spaces with parameter expansion, accept only the allowed keys, remove quotes around the value and exit with 2 on an unknown key. In a paragraph, explain why you might prefer such a reader to source deploy.env (hint: what would happen if the file contained $(rm -rf ...)?).

Our scripts can now make decisions, but they still pick arguments apart by hand: we look for -n in the first argument and do not understand it anywhere else. In the next post we move on to functions, positional parameters, shift and getopts, and build the bookstore-deploy.sh -e staging -v 1.4.2 --dry-run interface together with a good --help text.