I’ll admit it took me longer than I’d like to admit to stop confusing [, [[, and (( )) in Bash. Early on I’d copy-paste conditionals from old scripts without really understanding why one worked in one place and broke in another. Once I sat down and actually learned the differences, conditional logic in Bash stopped being a source of mysterious bugs and became one of the more predictable parts of my scripts. This article covers everything I wish someone had explained to me clearly from the start.
The Three Main Ways to Write Conditionals in Bash
Bash gives you three distinct syntaxes for conditional expressions, and each has its own quirks:
[ expression ]— the POSIX-compatible test command, also usable astest expression.[[ expression ]]— Bash’s extended test command, more powerful and generally safer.(( expression ))— arithmetic evaluation, used specifically for numeric comparisons.
Understanding when to use each one is the key to writing conditionals that actually behave the way you expect.
Step 1: Basic String Comparisons
#!/usr/bin/env bash
name="Alice"
if [ "$name" = "Alice" ]; then
echo "Hello, Alice!"
else
echo "Who are you?"
fi
Note the single = for string equality inside [ ]. You can also use != for inequality:
if [ "$name" != "Bob" ]; then
echo "You are not Bob."
fi
Step 2: Why Quoting Matters Inside ‘[ ]’
This is one of the most common sources of bugs I see in Bash scripts. If a variable is unset or contains spaces and isn’t quoted, [ ] can break in confusing ways:
name=""
if [ $name = "Alice" ]; then # BROKEN if $name is empty
echo "Match"
fi
Without quotes, an empty $name expands to nothing, leaving [ = "Alice" ], which is a syntax error because [ sees too few arguments. Always quote your variables inside [ ]:
if [ "$name" = "Alice" ]; then
echo "Match"
fi
Step 3: Using ‘[[ ]]’ for Safer, More Powerful Conditionals
[[ ]] is a Bash keyword (not an external command like [), and it handles quoting issues and word-splitting far more gracefully:
#!/usr/bin/env bash
name="Alice Marie"
if [[ $name == "Alice Marie" ]]; then
echo "Full match"
fi
if [[ $name == Alice* ]]; then
echo "Starts with Alice"
fi
Notice two things: [[ ]] doesn’t require quoting $name to avoid word-splitting issues (though I still quote it as a habit for consistency and clarity), and it supports pattern matching with * directly, which [ ] does not.
Step 4: Regex Matching with ‘[[ =~ ]]’
[[ ]] also supports full regular expression matching using =~:
#!/usr/bin/env bash
email="user@example.com"
if [[ "$email" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]]; then
echo "Valid email format."
else
echo "Invalid email format."
fi
This is far more powerful than anything [ ] can do, and it’s one of the main reasons I default to [[ ]] for any conditional beyond the simplest string comparison.
Step 5: Numeric Comparisons
For numbers, you have two options: the test-style operators inside [ ] or [[ ]], or full arithmetic evaluation with (( )).
Using test-style numeric operators:
age=20
if [ "$age" -ge 18 ]; then
echo "Adult"
fi
The common numeric test operators are -eq (equal), -ne (not equal), -gt (greater than), -ge (greater or equal), -lt (less than), and -le (less or equal).
Using arithmetic evaluation instead:
age=20
if (( age >= 18 )); then
echo "Adult"
fi
(( )) lets you use familiar mathematical operators (>=, <=, >, <, ==, !=) instead of the -ge/-le style flags, and it also supports full arithmetic like addition, subtraction, and modulo directly inside the condition.
Step 6: Combining Conditions with Logical Operators
Inside [[ ]], you can combine multiple conditions with && and || directly:
#!/usr/bin/env bash
age=25
has_id=true
if [[ "$age" -ge 18 && "$has_id" == "true" ]]; then
echo "Entry allowed."
else
echo "Entry denied."
fi
With the older [ ] syntax, you’d typically need separate bracket groups joined with -a/-o (which are largely discouraged now) or chain separate [ ] commands with shell-level &&/||:
if [ "$age" -ge 18 ] && [ "$has_id" = "true" ]; then
echo "Entry allowed."
fi
I strongly prefer the [[ ]] version for readability once more than one condition is involved.
Step 7: File Test Conditions
Bash includes a rich set of file test operators, usable in both [ ] and [[ ]]:
#!/usr/bin/env bash
FILE="/etc/hosts"
if [ -e "$FILE" ]; then
echo "File exists."
fi
if [ -f "$FILE" ]; then
echo "It's a regular file."
fi
if [ -d "/etc" ]; then
echo "It's a directory."
fi
if [ -r "$FILE" ]; then
echo "It's readable."
fi
if [ -w "$FILE" ]; then
echo "It's writable."
fi
if [ -x "/bin/ls" ]; then
echo "It's executable."
fi
if [ -s "$FILE" ]; then
echo "File is non-empty."
fi
These file test flags come up constantly in real scripts — checking whether a config file exists before sourcing it, verifying a directory is writable before attempting a backup, or confirming a script has execute permissions before trying to run it.
Step 8: Case Statements as an Alternative to Long If-Chains
For matching a single variable against many possible values, case is often cleaner than a long chain of if/elif:
#!/usr/bin/env bash
read -p "Enter environment (dev/staging/prod): " env
case "$env" in
dev)
echo "Using development configuration."
;;
staging)
echo "Using staging configuration."
;;
prod)
echo "Using production configuration. Proceed with caution."
;;
*)
echo "Unknown environment: $env"
exit 1
;;
esac
case also supports pattern matching, so you can match multiple values or wildcards per branch:
case "$env" in
dev|development)
echo "Development mode."
;;
prod*)
echo "Some flavor of production."
;;
esac
Real-World Use Cases
- Input validation, checking whether user-provided arguments match expected formats before proceeding, like validating an email or a numeric range.
- Environment-specific logic, branching script behavior based on whether it’s running in development, staging, or production.
- Pre-flight checks, verifying required files, directories, or permissions exist before a script performs its main task.
- Conditional deployment logic, like only restarting a service if a config file actually changed.
Automation Example: A Pre-Flight Check Script
#!/usr/bin/env bash
set -euo pipefail
CONFIG_FILE="/etc/myapp/config.yml"
DATA_DIR="/var/lib/myapp"
MIN_DISK_MB=500
if [[ ! -f "$CONFIG_FILE" ]]; then
echo "ERROR: Config file not found at $CONFIG_FILE"
exit 1
fi
if [[ ! -d "$DATA_DIR" ]]; then
echo "ERROR: Data directory not found at $DATA_DIR"
exit 1
fi
AVAILABLE_MB=$(df --output=avail -m "$DATA_DIR" | tail -1 | tr -d ' ')
if (( AVAILABLE_MB < MIN_DISK_MB )); then
echo "ERROR: Insufficient disk space. Need ${MIN_DISK_MB}MB, have ${AVAILABLE_MB}MB."
exit 1
fi
echo "All pre-flight checks passed. Starting application..."
This combines file existence checks, directory checks, and a numeric comparison to validate available disk space, exactly the kind of layered conditional logic that shows up in real deployment automation.
Best Practices
- Default to
[[ ]]for anything beyond the most trivial string equality check, since it handles quoting more safely and supports pattern/regex matching. - Use
(( ))specifically for numeric comparisons and arithmetic, since its syntax is far more readable than-gt/-ltstyle flags for anything involving actual math. - Always quote variables inside
[ ], even though it’s less strictly necessary inside[[ ]]. - Use
casestatements instead of longif/elifchains when matching one variable against several possible values. - Combine file test operators with clear error messages, so a failed pre-flight check tells the user exactly what’s missing.
Troubleshooting Common Issues
“[: too many arguments” or “[: unary operator expected” errors — This almost always means an unquoted variable expanded to nothing or to multiple words. Quote your variables: [ "$var" = "value" ] instead of [ $var = "value" ].
Numeric comparison with -eq fails on a variable that “looks like” a number — Check for hidden whitespace, non-numeric characters, or an empty value in the variable; -eq and friends require genuinely integer values.
Regex matching with =~ doesn’t behave as expected — Remember not to quote the regex pattern itself (only the variable being tested), since quoting the pattern can cause Bash to treat it as a literal string rather than a regex in some Bash versions.
A case statement isn’t matching a pattern I expect it to — Double-check you’re using shell glob patterns (*, ?, [...]), not full regex syntax, since case patterns follow glob rules, not the =~ regex rules used in [[ ]].
Common Mistakes to Avoid
- Mixing up
=(string comparison) and-eq(numeric comparison), which can silently produce wrong results since Bash won’t necessarily error on the mismatch. - Forgetting to quote variables inside
[ ], leading to cryptic syntax errors when a variable is empty or contains spaces. - Using
[ ]when you actually need pattern or regex matching, which it simply doesn’t support. - Writing long
if/elifchains for what’s really a simple value-matching scenario better suited to acasestatement.
Frequently Asked Questions
What’s the real difference between [ and [[? [ is effectively an external command (or a shell builtin mimicking one) that follows stricter POSIX rules, requiring careful quoting and offering no pattern matching. [[ is a Bash keyword parsed specially by the shell, offering safer handling of unquoted variables, glob pattern matching, and regex support via =~.
Should I ever use (( )) for something other than plain arithmetic? (( )) is specifically built for arithmetic contexts. While it can technically evaluate conditions with logical operators like &&, its real strength is numeric comparisons and math, and using it for string-based logic will lead to confusing behavior since strings get treated as numbers (usually zero) unless they look like valid numeric expressions.
Is [[ ]] portable to all shells, like sh or dash? No. [[ ]] is a Bash (and some other advanced shells like zsh, ksh) extension, not part of POSIX sh. If you need a script to run under strict POSIX sh, you’ll need to stick with [ ] and its more limited feature set.
Can I negate a condition in Bash? Yes, using ! before the expression: if [[ ! -f "$file" ]]; then echo "File does not exist"; fi checks the opposite of the file existence test.
Summary
Conditional expressions are the backbone of decision-making in Bash scripts, and understanding the distinct roles of [ ], [[ ]], and (( )) removes a huge amount of guesswork from writing them. Use [[ ]] as your default for string comparisons and pattern/regex matching, reach for (( )) when you’re doing genuine arithmetic, and fall back to case statements when you’re matching one variable against several possible values. Getting comfortable with these distinctions turns conditional logic from a recurring source of subtle bugs into one of the more reliable parts of your scripts.
