Conditionals In Depth
Master [[ ]] vs [ ] vs test, string and file tests, and compound conditions for robust Bash scripts.
- Explain the differences between [[ ]], [ ], and test
- Use string tests (-z, -n, =~) to validate input
- Use file tests (-f, -d, -r, -x) to check filesystem state
- Combine conditions with && and || inside [[ ]]
Beginner Bash uses if with simple string comparisons. Real scripts need more: checking whether a file exists before reading it, validating that a variable is non-empty, or matching a value against a regex. Bash provides three conditional constructs — [[ ]], [ ], and test — and knowing which one to reach for, and why, is what separates brittle one-liners from dependable scripts.
[[ ]] vs [ ] vs test
All three evaluate a condition and return an exit code (0 for true, 1 for false). The differences matter:
# test — the original POSIX command; most portable
test -f /etc/hosts && echo "exists"
# [ ] — an alias for test; POSIX, works in sh and bash
[ -f /etc/hosts ] && echo "exists"
# [[ ]] — a bash keyword; richer syntax, no word-splitting hazards
[[ -f /etc/hosts ]] && echo "exists"Prefer [[ ]] in all Bash scripts. It avoids two classic traps:
name=""
[ $name = "alice" ] # error: unary operator expected (empty var expands to nothing)
[[ $name = "alice" ]] # safe: [[ prevents word splitting on unquoted variablesQuoting inside [ ] is mandatory. An unquoted variable that expands to nothing or to a value with spaces breaks [ ] in ways that produce cryptic errors or silently wrong results. Inside [[ ]], Bash does the quoting for you — but quoting is still a good habit.
String tests
| Expression | Meaning |
|---|---|
[[ -z "$var" ]] | true if $var is empty |
[[ -n "$var" ]] | true if $var is non-empty |
[[ "$a" = "$b" ]] | string equality (also ==) |
[[ "$a" != "$b" ]] | string inequality |
[[ "$a" < "$b" ]] | lexicographic less-than |
[[ "$var" =~ ^[0-9]+$ ]] | regex match (ERE) |
The =~ operator matches the right-hand side as an extended regular expression:
input="42"
if [[ "$input" =~ ^[0-9]+$ ]]; then
echo "looks like a number"
fi
version="v1.2.3"
if [[ "$version" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
echo "major: ${BASH_REMATCH[1]}" # captured groups go into BASH_REMATCH
fiBASH_REMATCH is a special array populated after every =~ match. Index 0 is the full match; 1, 2, 3... are the capture groups. It is read-only and reset on every =~ evaluation.
File tests
File tests are how scripts confirm the world looks the way they expect before doing anything destructive.
[[ -e "$path" ]] # exists (file or directory)
[[ -f "$path" ]] # exists and is a regular file
[[ -d "$path" ]] # exists and is a directory
[[ -r "$path" ]] # exists and is readable
[[ -w "$path" ]] # exists and is writable
[[ -x "$path" ]] # exists and is executable
[[ -s "$path" ]] # exists and is non-empty (size > 0)
[[ -L "$path" ]] # exists and is a symbolic linkA defensive script checks before it acts:
config="$HOME/.myapprc"
if [[ ! -f "$config" ]]; then
echo "Config not found, creating default." >&2
cp /etc/myapp/default.conf "$config"
fi
if [[ ! -r "$config" ]]; then
echo "Cannot read $config" >&2
exit 1
fiCompound conditions
Inside [[ ]], use && and || to combine tests without nesting:
if [[ -f "$file" && -r "$file" ]]; then
cat "$file"
fi
if [[ -z "$USER" || "$USER" = "root" ]]; then
echo "Running as root or USER is unset"
fi
# Negate with !
if [[ ! -d "$dir" ]]; then
mkdir -p "$dir"
fiOutside [[ ]], you can also chain commands with && and || using short-circuit evaluation — a pattern you already know from pipes:
[[ -d "/tmp/work" ]] || mkdir /tmp/work
[[ -f "$lockfile" ]] && { echo "Lock exists, aborting."; exit 1; }Check your understanding
- 1.Which conditional construct is safest to use in Bash scripts and avoids word-splitting problems?
- 2.Which test checks that a path exists AND is a regular file (not a directory)?
- 3.After a successful =~ match, BASH_REMATCH[0] contains the first capture group.
Do it yourself
# Test string conditions
name=""
[[ -z "$name" ]] && echo "name is empty"
name="alice"
[[ -n "$name" ]] && echo "name is set: $name"
# Match a version string
ver="v2.10.4"
[[ "$ver" =~ ^v([0-9]+)\.([0-9]+) ]] && echo "major=${BASH_REMATCH[1]} minor=${BASH_REMATCH[2]}"
# File tests
[[ -f /etc/hostname ]] && echo "hostname file exists" || echo "not found"
[[ -d /tmp ]] && echo "/tmp is a directory"
[[ -x /bin/bash ]] && echo "bash is executable"
# Compound
[[ -f /etc/passwd && -r /etc/passwd ]] && echo "can read passwd"Where to go next
You can now interrogate strings and files precisely. The next lesson — Loops in depth — puts conditionals to work inside for, while read, and until loops, where the real scripting power lives.