Bash Conditionals: [[ ]], test, File Checks, and Operator Semantics
Choose Bash conditional forms deliberately, preserve argument boundaries, distinguish patterns from strings, and test filesystem state precisely.
Bash offers several ways to make a conditional decision, and they do not share one parser. The portable test utility, its bracket spelling, and Bash’s double-bracket compound command have different syntax and expansion rules. A reliable script chooses the form that matches its interpreter contract, quotes ordinary data, and treats every test result as a status rather than as printed text.
The square-bracket spelling is a command: the closing bracket is a required final argument. Double brackets are part of Bash’s grammar. That distinction explains why some expressions work in one form but not another and why the same unquoted variable can behave differently. This is not a reason to prefer one style everywhere; it is a reason to declare the required shell and test the exact syntax that deployment uses.
Use the syntax required by the portability contract
If a script promises POSIX sh, use the POSIX test operators and grammar. The test utility decides how to interpret its operands based on the number and arrangement of arguments. Quote parameter expansions so empty values remain explicit arguments and names beginning with a hyphen are not mistaken for missing operands. Avoid Bash-only conditionals in a file whose shebang or deployment contract says sh.
For a Bash-only script, the double-bracket form is generally easier to use with strings and patterns because word splitting and pathname expansion are not applied to its operands in the same way they are for ordinary command arguments. It also supports Bash conditional operators such as pattern matching with == and regular-expression matching with =~. Those operators still have their own semantics; a regular expression is not a glob, and a pattern on the right side of == may intentionally be interpreted as a pattern.
Do not combine single-bracket and double-bracket syntax in the same expression. For example, && is a shell list operator outside a conditional command, while inside [[ … ]] it can be a conditional operator. In the single-bracket command, a semicolon or another command separator is needed to join test results. Parentheses are parsed differently too. Use a style that makes the parser boundary obvious rather than relying on dense, shell-specific punctuation.
Preserve empty values and filenames
An unquoted expansion in an ordinary test command can disappear when it is empty, split into multiple arguments when it contains whitespace, or expand into filenames when it contains wildcard characters. Quote data passed to [ or test:
#!/usr/bin/env bash
set -u
if [[ $# -gt 0 ]]; then
value=$1
else
value=
fi
if [ -n "$value" ]; then
printf 'a value was provided\n'
fi
The explicit argument-count branch allows a missing first parameter to become an empty string even under nounset. The test receives a stable argument shape for a nonempty value and a valid quoted empty argument otherwise. A test expression should not be the first place an input gets accidentally split.
In Bash double brackets, ordinary words are not subject to word splitting or pathname expansion after parameter expansion. Quoting is still valuable because it makes intent clear and controls pattern interpretation. For a literal string comparison, quote the right-hand value. For a pattern match, leave the right-hand expression unquoted only when pattern matching is intended. Because this behavior differs from a normal command invocation, reviewers should be able to see which result the code wants.
Avoid a test such as [ $path = $expected ]. Either expansion can be empty or contain whitespace, and a path can begin with a hyphen. Use a correctly quoted portable test or a Bash conditional. For pathname existence, store the path as one value and pass it as one operand; do not try to repair malformed argument boundaries with extra quotes around the complete command string.
Choose a string, numeric, or file predicate
String tests answer whether a value is empty or whether two strings compare lexically. Numeric tests answer a numeric relationship and should be used only after the inputs satisfy the application’s numeric format. File tests answer a specific property of a pathname, such as whether it names a regular file, directory, symbolic link, readable file, or newer file.
Be precise about filesystem meaning. A test for a directory follows a symbolic link to a directory, while a symbolic-link test asks whether the path itself is a link. A path can exist but be unreadable by the current effective user. A readability predicate does not guarantee that a subsequent open will succeed because permissions, mount state, and concurrent changes can change between the check and the operation.
For a file that will be opened, prefer handling the actual open operation and its error rather than making a separate predicate check and assuming it stays true. The check-then-use interval creates a race: another process can replace, remove, or change the path after the test. A predicate is useful for user-facing diagnostics and branching, but it is not an authorization boundary.
When checking whether a file is newer than a reference, remember that timestamp granularity and filesystem behavior can affect close-together writes. A false result does not prove that no content changed. For synchronization or build correctness, compare content hashes, metadata designed for the workflow, or use a build-system dependency model rather than relying on a timestamp predicate alone.
Understand pattern and regular-expression contexts
Bash’s [[ command treats an unquoted right-hand side of == as a shell pattern. That is useful for a prefix or suffix test, but it is not the same operation as literal equality. Quoting the pattern changes the interpretation. A value that contains an asterisk may therefore mean either a wildcard or a literal asterisk depending on how the test is written.
Regular-expression matching with =~ has a separate rule set and different quoting concerns. Keep the expression in one variable when readability or reuse matters, and test its grouping, alternation, anchors, and backreferences against the Bash version the script supports. If the task only needs a simple fixed prefix, a pattern match is often simpler than a regular expression. If regular-expression behavior is central, make that a dedicated code path rather than using an operator interchangeably.
Do not use an untrusted string as shell code or rely on eval to make a test expression dynamic. Pass input as data and compare it using the selected operator. If an input must become a pattern, define the allowed pattern language and validate that boundary explicitly. Quoting prevents several expansion hazards, but it does not make a deliberately interpreted pattern literal.
Treat compound decisions as statuses
An if command executes a condition list and branches on its exit status. It does not require a special boolean type. A test command returns zero for true and nonzero for false; a command failure and a false condition are both nonzero at the shell level, so keep the distinction in mind when the command can fail for reasons other than a negative answer.
Use explicit branches when a result needs explanation:
if [[ -d $root ]]; then
printf 'directory exists: %s\n' "$root"
elif [[ -e $root ]]; then
printf 'path exists but is not a directory: %s\n' "$root"
else
printf 'path is missing: %s\n' "$root"
fi
The test can race with later filesystem operations, so this structure improves diagnosis but does not eliminate the need to handle errors from the operation that follows. Keep diagnostic output on an intentional stream and never convert a failed predicate into success merely to satisfy set -e.
Avoid Boolean expressions that are too dense to audit. A chain of && and || can rely on left-to-right list evaluation and status propagation, but subtle grouping can make a later action run when an earlier test was false. If the action is destructive or the expression combines multiple independent facts, use nested if statements or named helper functions with tests.
Check interpreter and version assumptions
The double-bracket command is not POSIX sh syntax. A script using it should identify Bash explicitly, and an installed Bash must be available at the path used in production. Older macOS systems ship Bash 3.2, which lacks some newer features. Do not use newer conditional operators without checking the oldest Bash version the script supports.
Test scripts under the actual interpreter, not an editor’s syntax highlighter or the interactive shell that happened to launch them. In CI, invoke Bash directly and print its version. For portable utilities, run shell syntax and behavior tests under a POSIX implementation such as dash or the platform’s /bin/sh, then run Bash-only tests separately.
Review checklist
For every condition, ask which grammar parses it, whether empty and whitespace-containing values preserve their argument boundaries, whether the right-hand side is a literal or a pattern, what a false result means, and whether the tested property can change before the subsequent operation. Check the interpreter version and avoid relying on private or accidental shell options.
Conditional syntax in shells looks compact, but its reliability comes from explicit parsing contracts. Choose POSIX test for portable scripts, Bash double brackets for their documented Bash behavior, and filesystem operations that handle their own errors for state-changing work. That clarity makes a one-line test far easier to maintain than a one-line debugging session.
Related:
- Bash Regular Expressions: Safe and Precise Matching with =~
- How to Write Robust, Portable POSIX Shell Scripts
Sources: