Bash — The Built-in [[ ]] Conditional Expression

Discover Bash's powerful `[[ ]]` conditional expression: safer string and numeric comparisons, pattern matching, regular expressions, file tests, and logical operators—without the common pitfalls of `[ ]` and `test`

Bash — The Built-in [[ ]] Conditional Expression

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:

OperatorMeaning
-eqequal
-nenot equal
-ltless than
-leless than or equal
-gtgreater than
-gegreater 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/bash

For 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.

Read next

Bash — Variables and Parameter Expansion

Learn how Bash variables work, from basic assignments and default values to advanced parameter expansion. Discover how to manipulate strings, extract substrings, replace text, change letter case, and use variables more effectively in your scripts.

Bash — The for Loop

Learn how Bash’s for loop automates repetitive tasks, from counting through numbers to processing files, arrays, and command-line arguments. Explore both loop styles, practical examples, and common pitfalls.