If you write shell scripts in Bash, sooner or later you will need to check conditions:
- whether a file exists;
- whether a variable is empty;
- whether two strings are equal;
- whether one number is greater than another;
- whether the previous command completed successfully;
- whether a string matches a regular expression.
Bash provides several ways to do this:
test ...
or:
[ ... ]
and, finally:
[[ ... ]]
The last one is particularly interesting.
[[ ... ]] is a Bash built-in conditional expression, and a more powerful and safer alternative to the classic [ ... ] syntax, which is itself an alternative form of the test command.
At first glance, the difference looks rather small:
[ "$var" = "hello" ]
versus:
[[ $var == hello ]]
But inside Bash, there are several important differences.
What is [[ ]]?
Let's start with something simple.
Suppose we have two variables:
#!/usr/bin/env bash
var_1=3
var_2=5
if [[ $var_1 -lt $var_2 ]]; then
echo "The second number is greater than the first"
fi
The result is:
The second number is greater than the first
Here:
[[ $var_1 -lt $var_2 ]]
checks whether the value of var_1 is less than the value of var_2.
But [[ ... ]] can do much more.
Why are there several ways to test conditions?
If you've worked with shell scripts before, you've probably seen constructions like:
if [ "$var" = "hello" ]; then
...
fi
or:
if test "$var" = "hello"; then
...
fi
or:
if [[ $var == hello ]]; then
...
fi
They look similar, but they are not the same thing.
Historically, the test command came first and was designed to evaluate various conditions.
For example:
test -f file.txt
checks whether file.txt exists and is a regular file.
Shell syntax also provides:
[ -f file.txt ]
which is essentially another way of invoking test.
So these two:
test -f file.txt
and:
[ -f file.txt ]
have practically the same behavior.
But:
[[ -f file.txt ]]
is something different.
It is a special Bash conditional expression.
And that distinction is important.
[[ ]] is not the test command
This is one of those details that is useful to understand correctly.
In Bash:
[[ ... ]]
is not an external program.
In fact, it isn't an ordinary shell command either. Bash parses [[ ... ]] as a special part of its own syntax.
This means Bash knows that the contents of:
[[ ... ]]
are supposed to be interpreted as a conditional expression.
For example:
[[ $name == "Pavel" ]]
Bash handles this expression according to the rules of its conditional-expression syntax rather than treating its contents like ordinary command arguments.
This is one of the reasons [[ ]] is so convenient for more complex conditions.
The most important difference: no word splitting
This is where things start getting interesting.
Suppose we have:
name="John Smith"
With [ ], you normally need to write:
if [ "$name" = "John Smith" ]; then
echo "Found"
fi
The quotes are important.
If you write:
if [ $name = "John Smith" ]; then
the shell performs word splitting on $name.
The value:
John Smith
becomes two separate words.
The resulting command is effectively closer to:
[ John Smith = "John Smith" ]
which is not what we intended.
With [[ ]], things are much nicer:
if [[ $name == "John Smith" ]]; then
echo "Found"
fi
The quotes around $name are normally not required.
You could also write:
if [[ $name == John\ Smith ]]; then
echo "Found"
fi
but the first version is much easier to read.
What about *, ?, and other special characters?
There is another important difference.
With the traditional [ ] syntax:
[ $file = *.txt ]
you can get unexpected results because *.txt may undergo pathname expansion before [ receives its arguments.
With [[ ]], ordinary pathname expansion does not happen.
For example:
file="report.txt"
if [[ $file == *.txt ]]; then
echo "This is a text file"
fi
The result is:
This is a text file
Here, *.txt is being used as a pattern for string matching.
This is one of the particularly useful features of [[ ]].
String comparisons
The simplest example is:
name="Pavel"
if [[ $name == "Pavel" ]]; then
echo "Hello Pavel"
fi
You can use either:
=
or:
==
For example:
[[ $name = "Pavel" ]]
and:
[[ $name == "Pavel" ]]
produce the same result for an ordinary string comparison.
I generally prefer == because it visually makes it obvious that we are comparing two values.
Pattern matching with ==
This is where == becomes particularly interesting.
filename="backup-2026.tar.gz"
if [[ $filename == backup-*.tar.gz ]]; then
echo "This is a backup"
fi
Here * means:
any number of any characters
So the condition matches:
backup-2026.tar.gz
backup-server.tar.gz
backup-home.tar.gz
backup-12345.tar.gz
but not:
database-2026.tar.gz
This is shell pattern matching, not a regular expression.
Another example
We can check a file extension:
filename="photo.jpg"
if [[ $filename == *.jpg || $filename == *.jpeg ]]; then
echo "JPEG image"
fi
Here we are checking the string against two patterns.
!= — negative comparison
If you need to check that two strings are not equal:
if [[ $name != "root" ]]; then
echo "This is not root"
fi
You can also use a pattern:
if [[ $filename != *.tmp ]]; then
echo "This is not a temporary file"
fi
Numeric comparisons
This is where it is important not to confuse string comparisons with numeric comparisons.
For numbers, [[ ]] provides dedicated operators:
| Operator | Meaning |
|---|---|
-eq | equal |
-ne | not equal |
-lt | less than |
-le | less than or equal |
-gt | greater than |
-ge | greater than or equal |
For example:
a=10
b=20
if [[ $a -lt $b ]]; then
echo "$a is less than $b"
fi
The result is:
10 is less than 20
The basic forms are:
[[ $a -eq $b ]]
Equal.
[[ $a -ne $b ]]
Not equal.
[[ $a -lt $b ]]
Less than.
[[ $a -le $b ]]
Less than or equal.
[[ $a -gt $b ]]
Greater than.
[[ $a -ge $b ]]
Greater than or equal.
Why can't we simply use < and >?
We can, but there is an important detail.
Inside:
[[ ... ]]
the operators:
<
>
perform lexicographical string comparison, not numeric comparison.
For example:
[[ "apple" < "banana" ]]
is true because apple comes before banana lexicographically.
But:
[[ 10 < 2 ]]
can also be true as a string comparison.
Why?
Because the string:
10
is compared with:
2
and the first character, 1, comes before 2.
For numbers, use:
[[ 10 -lt 2 ]]
rather than:
[[ 10 < 2 ]]
Empty variables
A very common task is:
Does this variable contain a value?
For example:
name="Pavel"
if [[ -n $name ]]; then
echo "Name is set"
fi
-n means that the string has a non-zero length.
For an empty string, use:
-z
For example:
if [[ -z $name ]]; then
echo "Name is not set"
fi
-z means that the string has zero length.
So:
[[ -n $value ]]
means "the string is not empty", while:
[[ -z $value ]]
means "the string is empty".
File tests
This is one of the most useful groups of conditional operators.
Check whether an object exists:
if [[ -e $file ]]; then
echo "Object exists"
fi
Check whether it is a regular file:
if [[ -f $file ]]; then
echo "This is a regular file"
fi
Check whether it is a directory:
if [[ -d $directory ]]; then
echo "This is a directory"
fi
Check whether it is a symbolic link:
if [[ -L $link ]]; then
echo "This is a symbolic link"
fi
Check whether a file is readable:
if [[ -r $file ]]; then
echo "File is readable"
fi
Writable:
if [[ -w $file ]]; then
echo "File is writable"
fi
Executable:
if [[ -x $file ]]; then
echo "File is executable"
fi
Checking whether a directory exists
A very common pattern is:
directory="/var/backups"
if [[ ! -d $directory ]]; then
echo "Creating directory"
mkdir -p "$directory"
fi
Here we have another operator:
!
It negates the condition.
So:
[[ -d $directory ]]
means:
the directory exists.
While:
[[ ! -d $directory ]]
means:
the directory does not exist.
Logical operators
Conditions can be combined.
&& means AND.
For example:
if [[ -f $file && -r $file ]]; then
echo "File exists and is readable"
fi
Both conditions must be true.
|| means OR:
if [[ $user == "root" || $user == "admin" ]]; then
echo "Privileged user"
fi
Only one of the conditions needs to be true.
And ! means NOT:
if [[ ! -f $file ]]; then
echo "File is missing"
fi
Complex conditions
Because conditions can be combined, you can write fairly sophisticated checks without creating a huge collection of nested if statements.
For example:
if [[ -f $file && -r $file && $size -gt 1024 ]]; then
echo "File exists, is readable, and is large enough"
fi
Or:
if [[ $environment == "production" && $debug != "true" ]]; then
echo "Starting production mode"
fi
The resulting code is often compact without becoming cryptic.
Regular expressions with =~
This is one of the most powerful features of [[ ]].
The:
=~
operator allows you to test whether a string matches a regular expression.
For example:
version="1.25.3"
if [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "This is a version in X.Y.Z format"
fi
The expression checks for:
number.number.number
So these match:
1.25.3
2.0.0
10.12.42
while these do not:
v1.25.3
1.25
version-1.2.3
An interesting detail — BASH_REMATCH
After a successful regular-expression match, Bash stores the results in the BASH_REMATCH array.
For example:
version="Ubuntu 24.04"
if [[ $version =~ ^([A-Za-z]+)[[:space:]]+([0-9]+\.[0-9]+)$ ]]; then
echo "Distribution: ${BASH_REMATCH[1]}"
echo "Version: ${BASH_REMATCH[2]}"
fi
The result is:
Distribution: Ubuntu
Version: 24.04
So [[ ]] can do more than simply answer:
Does this string match?
It can also help extract parts of the matched string.
Be careful with quotes when using =~
There is an important detail here.
This:
[[ $version =~ ^[0-9]+\.[0-9]+$ ]]
treats the right-hand side as a regular expression.
You should not blindly put the entire regular expression inside quotes:
[[ $version =~ "^[0-9]+\.[0-9]+$" ]]
Quoted and unquoted portions of the regular expression are handled differently by Bash, so quoting can change the meaning of the expression.
If the regular expression is stored in a variable, it is often convenient to write:
regex='^[0-9]+\.[0-9]+$'
if [[ $version =~ $regex ]]; then
echo "Valid version"
fi
The exact quoting rules around =~ are worth learning if you use regular expressions heavily in Bash.
Variables inside [[ ]] usually don't need quotes
This is probably one of the reasons I prefer [[ ]] in Bash scripts.
You can write:
if [[ $name == "Pavel" ]]; then
instead of:
if [ "$name" = "Pavel" ]; then
And:
if [[ -f $file ]]; then
instead of:
if [ -f "$file" ]; then
But there is an important caveat:
This does not mean that quotes are no longer necessary.
Inside [[ ]], Bash deliberately prevents normal word splitting and pathname expansion, but quoting can still affect the meaning of an expression, particularly when patterns or regular expressions are involved.
A better rule is:
In simple [[ ]] expressions, variables usually do not need to be quoted. But you still need to understand where quoting changes the semantics.Why [[ ]] is safer with variables
Consider:
filename="My Documents/report.txt"
With [ ], you need to remember the quotes:
if [ -f "$filename" ]; then
echo "File exists"
fi
With [[ ]]:
if [[ -f $filename ]]; then
echo "File exists"
fi
the spaces inside the variable's value do not cause word splitting.
This is more than a convenience.
A huge number of shell-script bugs are caused by incorrect quoting. [[ ]] allows Bash to handle some of these cases more safely for you.
[[ ]] and && / ||
There is another important difference from [ ].
Inside:
[[ ... ]]
you can directly use:
&&
and:
||
For example:
if [[ $os == "ubuntu" && $arch == "amd64" ]]; then
echo "Ubuntu on x86-64"
fi
You can also write a more complex condition:
if [[ $os == "ubuntu" || $os == "debian" ]] &&
[[ $arch == "amd64" ]]; then
echo "Debian-based x86-64 system"
fi
This can make complex conditions considerably easier to read.
[[ ]] isn't only for if
This is also important.
You can use [[ ]] directly as a conditional command:
if [[ $value -gt 10 ]]; then
echo "Large"
fi
But [[ ]] itself returns a normal shell exit status.
Therefore, you could write:
[[ $value -gt 10 ]]
and then inspect $?.
In most cases, however, that isn't necessary.
This is much clearer:
if [[ $value -gt 10 ]]; then
...
fi
Using [[ ]] with while
For example:
counter=0
while [[ $counter -lt 5 ]]; do
echo "Counter: $counter"
((counter++))
done
The result is:
Counter: 0
Counter: 1
Counter: 2
Counter: 3
Counter: 4
Using [[ ]] with until
You can use it with until as well:
counter=0
until [[ $counter -ge 5 ]]; do
echo "Counter: $counter"
((counter++))
done
The same conditional-expression rules apply.
Checking an environment variable
Suppose a script should behave differently in production and development:
environment=${ENVIRONMENT:-development}
if [[ $environment == "production" ]]; then
echo "Production mode"
else
echo "Development mode"
fi
Here we are also using a very useful Bash idiom:
${ENVIRONMENT:-development}
If ENVIRONMENT is unset or empty, development is used instead.
This is a Bash parameter-expansion feature, not part of [[ ]] itself, but it often appears alongside conditional expressions in real scripts.
A practical example: checking an argument
Suppose our script accepts an environment name:
#!/usr/bin/env bash
environment=$1
if [[ $environment == "production" ]]; then
echo "Starting production"
elif [[ $environment == "staging" ]]; then
echo "Starting staging"
elif [[ $environment == "development" ]]; then
echo "Starting development"
else
echo "Unknown environment: $environment"
exit 1
fi
Running:
./deploy.sh production
produces:
Starting production
Checking several allowed values
If there are only a couple of possible values, you can combine conditions:
if [[ $environment == "production" ||
$environment == "staging" ]]; then
echo "Remote environment"
fi
But if the number of alternatives starts growing, case may be a better choice:
case $environment in
production|staging)
echo "Remote environment"
;;
development)
echo "Local environment"
;;
*)
echo "Unknown environment"
exit 1
;;
esac
[[ ]] should not become an attempt to solve every possible problem.
Sometimes another Bash construct is simply a better fit.
A very important example: checking a command's result
Because Bash conditions are based on exit status, you can write:
if command; then
echo "Command succeeded"
else
echo "Command failed"
fi
For example:
if ping -c 1 server.example.com >/dev/null 2>&1; then
echo "Server is reachable"
else
echo "Server is unavailable"
fi
Here, [[ ]] isn't needed at all.
And this is an important principle:
Use[[ ]]when you need to construct a conditional expression. If the command itself already returns the appropriate exit status, there is usually no need to check$?with[[ ]].
A less desirable approach is:
ping -c 1 server.example.com
if [[ $? -eq 0 ]]; then
echo "OK"
fi
Much better:
if ping -c 1 server.example.com >/dev/null 2>&1; then
echo "OK"
fi
Besides being shorter, this avoids accidentally running another command between the command being tested and the $?check.
Does every shell support [[ ]]?
Here we reach an important limitation.
[[ ]] is not POSIX syntax.
It is a Bash feature, also supported by some other shells.
Therefore:
#!/bin/sh
does not mean:
this script will run under Bash.
On Ubuntu, /bin/sh normally points to dash, and dash does not support the Bash-specific:
[[ ... ]]
construct.
Therefore, if your script uses [[ ]], use:
#!/usr/bin/env bash
or:
#!/bin/bashFor example, don't do this
#!/bin/sh
if [[ $name == "Pavel" ]]; then
echo "Hello"
fi
If /bin/sh points to a shell without [[ ]] support, the script will fail.
Instead:
#!/usr/bin/env bash
if [[ $name == "Pavel" ]]; then
echo "Hello"
fi
Now you have explicitly told the system:
This script requires Bash.
[ versus [[
Let's compare them in practice.
Traditional version
if [ "$name" = "Pavel" ]; then
echo "Hello"
fi
Bash version
if [[ $name == "Pavel" ]]; then
echo "Hello"
fi
For Bash scripts, I generally prefer the second form because it:
- is easier to read;
- avoids many of the quoting problems associated with
[ ]; - supports
&&and||directly inside the expression; - supports shell pattern matching;
- supports
=~regular expressions; - provides
BASH_REMATCH; - is part of Bash's own conditional-expression syntax.
But [[ ]] is not a universal solution
If you are writing a POSIX shell script:
#!/bin/sh
and want it to work across the widest possible range of systems, you cannot rely on [[ ]].
In that case, use:
[ ... ]
or:
test ...
For example:
if [ "$name" = "Pavel" ]; then
echo "Hello"
fi
For a Bash script, however:
[[ ... ]]
is generally the preferred choice.
Useful tests at a glance
File exists
[[ -e $file ]]
Regular file
[[ -f $file ]]
Directory
[[ -d $dir ]]Empty string
[[ -z $value ]]
Non-empty string
[[ -n $value ]]
Strings are equal
[[ $a == $b ]]
Strings are not equal
[[ $a != $b ]]
Number is greater
[[ $a -gt $b ]]
Number is less
[[ $a -lt $b ]]
String matches a pattern
[[ $file == *.log ]]
String matches a regular expression
[[ $version =~ ^[0-9]+\.[0-9]+$ ]]
Negation
[[ ! -f $file ]]
Multiple conditions
[[ -f $file && -r $file ]]
A small real-world example
Suppose we have a deployment script:
#!/usr/bin/env bash
environment=${1:-development}
config="config/${environment}.conf"
if [[ ! -f $config ]]; then
echo "Configuration file not found: $config"
exit 1
fi
if [[ $environment == "production" ]]; then
echo "WARNING: production deployment"
fi
if [[ $environment == "production" && ! -r $config ]]; then
echo "Production configuration is not readable"
exit 1
fi
echo "Using configuration: $config"
Running:
./deploy.sh production
might produce:
WARNING: production deployment
Using configuration: config/production.conf
This is where [[ ]] really starts to shine. Conditions remain compact while still being easy to understand.
A few traps to remember
[[ ]] is Bash-specific
If the script needs to run under:
sh
dash
busybox sh
don't automatically use [[ ]].
< and > compare strings
For numbers:
[[ $a -lt $b ]]
not:
[[ $a < $b ]]
== can perform pattern matching
For example:
[[ $file == *.log ]]
does not mean that the string must literally equal:
*.log
Here *.log is a shell pattern.
If you need to compare against the literal string *.log, the pattern characters must be quoted or otherwise escaped appropriately.
=~ uses regular expressions
Don't confuse:
[[ $file == *.log ]]
with:
[[ $file =~ \.log$ ]]
The first uses shell pattern matching.
The second uses a regular expression.
They are two different mechanisms.
Why I would use [[ ]] in new Bash scripts
If a script already requires Bash:
#!/usr/bin/env bash
then using:
[[ ... ]]
usually makes the code simpler and more robust.
For example:
if [[ -f $config && $environment == production ]]; then
...
fi
looks considerably nicer than trying to construct the same condition using the older [ ] syntax.
And when you need sophisticated string matching:
if [[ $version =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
...
fi
the capabilities of [ ] simply aren't enough.
One more useful trick
Bash can show you documentation for its built-in commands and constructs.
For example:
help test
shows information about the built-in test command.
And:
help [
shows information about [ ].
For [[ ]], you can use:
help [[
Depending on the Bash version, the exact output may vary.
You can also consult the Bash manual:
man bash
and look for the section:
CONDITIONAL EXPRESSIONS
That section is worth knowing if you spend a lot of time writing Bash scripts.
Summary
If we ignore most of the historical details, there is a very simple rule to remember.
POSIX shell
Use:
[ ... ]
or:
test ...
Bash
If your script uses Bash:
#!/usr/bin/env bash
then for conditional expressions, you will usually want to use:
[[ ... ]]
because it provides:
- string comparisons;
- numeric comparisons;
- file tests;
- empty/non-empty string tests;
- logical
&&,||, and!; - shell pattern matching;
- regular expressions through
=~; BASH_REMATCH;- safer handling of variables without normal word splitting and pathname expansion.
And, perhaps most importantly, [[ ]] makes Bash code considerably easier to read.
For example:
if [[ -f $config && $environment == production ]]; then
echo "Production configuration found"
fi
In my opinion, this is almost readable as plain English:
if config is a file and environment is production, then...
And that is probably the biggest advantage of [[ ]]: it isn't just another way to test a condition. It is Bash syntax specifically designed to make conditional expressions easier and safer to write.
Quick Reference
# Strings
[[ $a == $b ]]
[[ $a != $b ]]
# Pattern matching
[[ $file == *.log ]]
[[ $file != *.tmp ]]
# Numbers
[[ $a -eq $b ]]
[[ $a -ne $b ]]
[[ $a -lt $b ]]
[[ $a -le $b ]]
[[ $a -gt $b ]]
[[ $a -ge $b ]]
# Strings
[[ -z $value ]]
[[ -n $value ]]
# Files
[[ -e $file ]]
[[ -f $file ]]
[[ -d $dir ]]
[[ -L $link ]]
[[ -r $file ]]
[[ -w $file ]]
[[ -x $file ]]
# Logic
[[ ! condition ]]
[[ condition1 && condition2 ]]
[[ condition1 || condition2 ]]
# Regular expression
[[ $value =~ regex ]]
# Regex results
${BASH_REMATCH[0]}
${BASH_REMATCH[1]}
${BASH_REMATCH[2]}
And if you see something like this in an old shell script:
if [ "$foo" = "bar" ]; then
don't immediately rewrite it as:
if [[ $foo == bar ]]; then
First look at the script's shebang:
#!/bin/sh
or:
#!/usr/bin/env bash
Because that is what tells you whether you are actually allowed to use Bash-specific syntax.