Bash if, case and Loops: [[ ]] vs [ ], Tests and Loop Pitfalls
Bash conditions and loops: if, [ ] versus [[ ]], regex and glob matching, case, for, while, until, select, and the subshell and stdin traps.
In most languages, if tests a true-or-false value. In Bash, if runs a command and looks at its exit status: 0 means true, anything else means false. [, [[ and (( are just commands (or keywords) that happen to exist for testing, and grep -q, command -v or your own function work just as well as a condition.
This part covers conditions and loops with that model in mind: if with any command, the real differences between [ ] and [[ ]], glob and regex matching, case, every kind of loop, and the two loop traps that cause the most confusing bugs: variables lost in a subshell and commands that swallow the loop's input.
Prerequisites
- Exit status and pipelines from Part 2, and variables from Part 4.
- The practice directory in
~/bash-practice.
if Runs a Command
Test the command directly instead of checking $? afterwards. cmd; if [ $? -ne 0 ]; then works, but it is longer, and anything you add between the two lines (a log message, say) silently changes $?. ShellCheck flags the indirect form as SC2181.
The full form, with elif and else:
if [[ -f "$config" ]]; then
echo "using $config"
elif [[ -f /etc/notes-api.conf ]]; then
config=/etc/notes-api.conf
else
echo "no configuration found" >&2
exit 1
fi
[ ] Versus [[ ]]
[ is an ordinary command (a builtin, and also /usr/bin/[), so its arguments go through word splitting and globbing like any other command's. [[ is a keyword: Bash parses what is inside it specially, without splitting or globbing variables. That one difference explains every row in this table:
| Behaviour | [ ... ] (POSIX test) |
[[ ... ]] (Bash) |
|---|---|---|
| Unquoted empty or multi-word variable | Syntax error or wrong result | Works |
< and > |
Redirections (create files!) | String comparison |
| Glob pattern match | Not available | [[ $s == *.log ]] |
| Regular expressions | Not available | [[ $s =~ ^[0-9]+$ ]] |
| And, or, grouping | [ a ] && [ b ]; -a, -o are obsolete |
&&, ||, ( ) inside |
Works in POSIX sh |
Yes | No (not in dash; BusyBox ash has it in most builds) |
With [ ], quote every variable ([ "$answer" = yes ]) and write -gt for numbers. In Bash scripts, use [[ ]] for strings and files and (( )) for numbers, and keep [ ] for scripts that must run under sh.
One trap remains in [[ ]]: < and > compare strings in dictionary order, not numbers:
Glob and Regex Matching
Inside [[ ]], == (or =) with an unquoted right-hand side is a glob pattern match. Quote the right side when you want a literal comparison:
=~ matches an extended regular expression. Store the regex in a variable: quoting it inside [[ ]] would make it literal, and special characters in an inline regex are hard to get right. Capture groups end up in the BASH_REMATCH array:
#!/usr/bin/env bash
# parse-line.sh: pull fields out of access log lines with a regex
re='^([0-9.]+) [^"]*"([A-Z]+) ([^ ]+) [^"]*" ([0-9]{3}) ([0-9]+)'
while IFS= read -r line; do
if [[ $line =~ $re ]]; then
printf 'ip=%-14s method=%-4s path=%-18s status=%s bytes=%s\n' \
"${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}" \
"${BASH_REMATCH[4]}" "${BASH_REMATCH[5]}"
else
echo "no match: $line" >&2
fi
done < <(head -n 4 logs/access.log; echo "garbage line")
BASH_REMATCH[0] is the whole match and [1] onwards are the groups. Bash uses the system's POSIX regex library, so there is no \d, no lazy *? and no lookahead; write [0-9] and [^"]* instead. An invalid regex makes the test return status 2. Bash 5.2 fails silently, which is easy to mistake for "no match"; Bash 5.3 also prints an error:
Arithmetic Conditions
(( )) is the clearest way to compare numbers, and it accepts C-style operators (==, !=, <, >=, &&, ||, %):
if (( attempts >= max_attempts )); then echo "giving up" >&2; exit 1; fi
(( port % 2 == 0 )) && echo "even port"
Only use it on values you have validated as numbers. Arithmetic evaluates names and array subscripts recursively, so a string from outside the script can run commands:
The same applies to [[ $input -gt 5 ]], $(( input )) and declare -i. Part 8 has validation functions for numbers, ports and IP addresses.
case
case compares one value against glob patterns, top to bottom, and runs the first match. It is clearer than a chain of elif and works on every shell:
#!/usr/bin/env bash
# classify.sh: describe HTTP status codes given as arguments
for code in "$@"; do
case "$code" in
*[!0-9]* | "") echo "$code: not a number" ;; # checked first, so ? below means a digit
2??) echo "$code: success" ;;
301 | 302) echo "$code: redirect" ;;
3??) echo "$code: other redirection" ;;
404) echo "$code: not found" ;& # ;& falls through into the next block
4??) echo "$code: client error" ;;
5??) echo "$code: server error" ;;
*) echo "$code: unknown" ;;
esac
done
Each block ends with ;;. Bash also has ;&, which runs the next block without testing its pattern (used above for 404), and ;;&, which goes on testing the remaining patterns. Both need Bash 4.0+. shopt -s nocasematch makes case and [[ == ]] ignore case.
for Loops
for name; do without in loops over the script's arguments, the same as for name in "$@". Ranges with variables need the C-style form or seq, because brace expansion happens before variables are expanded (Part 2).
Do not loop over $(cat file): that loops over words, not lines.
while and until
while repeats as long as its condition command succeeds, and until as long as it fails. A typical use in deployment scripts is waiting for something to become ready, with a time limit:
#!/usr/bin/env bash
# wait-for-port.sh HOST PORT [SECONDS]: wait until a TCP port accepts connections
if (($# < 2)) || [[ ! $2 =~ ^[1-9][0-9]{0,4}$ || ! ${3:-10} =~ ^[1-9][0-9]{0,3}$ ]] || (($2 > 65535)); then
echo "usage: wait-for-port.sh HOST PORT [SECONDS]" >&2
exit 2
fi
host=$1 port=$2 limit=${3:-10}
SECONDS=0
# Bash opens a TCP connection when you redirect to /dev/tcp/HOST/PORT.
# timeout stops an attempt that hangs, for example behind a firewall that drops packets.
# shellcheck disable=SC2016 # $1 and $2 belong to the inner bash, so single quotes are right
until timeout 1 bash -c 'exec 3<>"/dev/tcp/$1/$2"' _ "$host" "$port" 2>/dev/null; do
if ((SECONDS >= limit)); then
echo "$host:$port is still closed after ${limit}s" >&2
exit 1
fi
sleep 0.5
done
echo "$host:$port is open after ${SECONDS}s"
The demo below starts a listener with nc, which the netcat-openbsd package provides on Debian and Ubuntu:
/dev/tcp/HOST/PORT is not a real file: Bash itself opens a TCP connection when you redirect to such a path (it can be disabled at compile time, and BusyBox ash and dash do not have it). The script validates its arguments before using them in arithmetic, and runs each attempt under timeout 1, because a connection to a host that silently drops packets can hang for minutes. The host and port reach the inner bash -c as arguments, not pasted into the command string. Container entrypoints often use this pattern to wait for a database before starting an application; where they exist, dedicated checks such as pg_isready or curl --fail give clearer errors.
while true; do ... done (or while :) loops until a break. break 2 and continue 2 act on the second enclosing loop when loops are nested.
select Menus
select prints a numbered menu on standard error, reads a choice from standard input, and puts the chosen word in a variable. It loops until you break:
#!/usr/bin/env bash
# menu.sh: pick an environment from a numbered list
PS3="Deploy to which environment? "
select env in staging production quit; do
case "$env" in
staging | production) echo "deploying to $env"; break ;;
quit) echo "cancelled"; break ;;
*) echo "invalid choice: $REPLY" ;;
esac
done
The input 7 was not on the menu, so env was empty and $REPLY held the raw answer; 1 then picked staging. Menus are fine for scripts that people run by hand. Anything that also runs in CI should take the choice as an argument (Part 8).
Loops and Subshells: The Lost Variable
Each command in a pipeline runs in its own subshell. When a loop is on the right side of a pipe, its variables vanish when the pipeline ends:
Process substitution (< <(command)) is the usual fix. lastpipe needs Bash 4.2+ and only works when job control is off, which is the default in scripts but not in an interactive terminal. And for a plain count, grep -c ' 404 ' logs/access.log needs no loop at all.
Commands That Swallow the Loop's Input
In while read ... done < file, every command inside the loop shares the loop's standard input. A command that reads standard input takes the rest of the file, and the loop ends early. ssh is the classic culprit: while read host; do ssh "$host" uptime; done < hosts connects to the first host only. Here cat > /dev/null stands in for ssh:
Reading the list from another file descriptor (read -u 3 ... 3< file) keeps standard input free. For ssh specifically, ssh -n (or < /dev/null on the command) also works.
Common Mistakes
if [ $? -eq 0 ]after a command. Put the command in theif.- Unquoted variables in
[ ], and<or>in[ ]. Use[[ ]], or quote everything and use-ltand-gt. - Comparing numbers with
<in[[ ]]. It compares strings; use(( ))or-lt. - A quoted regex after
=~. It becomes a literal string. Put the regex in a variable. - Arithmetic on input you have not validated. It can run commands.
for line in $(cat file). Usewhile IFS= read -r line.- A loop at the end of a pipe that sets variables. Use
done < <(command). sshor other stdin readers inside awhile readloop. Useread -u 3orssh -n.
Summary
if,whileanduntilrun commands and test the exit status.- In Bash, prefer
[[ ]]for strings, files, patterns and regexes, and(( ))for numbers; keep[ ]forshscripts. =~puts capture groups inBASH_REMATCH; keep the regex in a variable.casematches glob patterns;;∧;&change what happens after a match.- Loop over globs,
while IFS= read -rlines and C-style ranges, never over$(cat)or$(ls). - Pipelines run loops in subshells, and loop bodies share the loop's standard input: plan for both.
Next: Functions, where scope, return values and libraries come in.
Keep Learning
Tags
Found an issue?