Code of the Day
IntermediateScripting patterns

Conditionals In Depth

Master [[ ]] vs [ ] vs test, string and file tests, and compound conditions for robust Bash scripts.

BashIntermediate11 min read
Recommended first
By the end of this lesson you will be able to:
  • 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 (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 variables

Quoting 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

ExpressionMeaning
[[ -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
fi

BASH_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 link

A 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
fi

Compound 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"
fi

Outside [[ ]], 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. 1.
    Which conditional construct is safest to use in Bash scripts and avoids word-splitting problems?
  2. 2.
    Which test checks that a path exists AND is a regular file (not a directory)?
  3. 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.

Finished reading? Mark it complete to track your progress.

On this page