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 !: Negating Command Results in Bash

Master the Bash ! operator to easily reverse exit statuses and trigger actions when a command fails. This quick guide breaks down how the logical NOT operator transforms success into failure (and vice versa) with practical scripting examples.

Bash: Getting Started with the Basic Concepts

Bash may look intimidating, but it is simply a program waiting for commands. Learn how Bash works, finds commands, handles files and patterns, and combines commands using pipes, redirection, variables, conditions, and more.