1. Introduction

The test or [ builtin is a Bash-specific construct that can evaluate conditionals. It starts with test and ends after submitting the command or starts with [ and ends with ]. When working within the context of [], there are separate operators based on the different data types.

In this tutorial, we explore how Bash comparisons work depending on the compared values and context. First, we briefly refresh our knowledge about data types. Next, we understand shell number representation. After that, we thoroughly go over the test or [] command syntax. Finally, we briefly discuss Bash-specific features related to comparisons and conditionals.

We tested the code in this tutorial on Debian 12 (Bookworm) with GNU Bash 5.2.15. It should work in most POSIX-compliant environments unless otherwise specified.

2. Data Types

Data types exist in many languages. Their role is to introduce type safety as well as promote optimization.

For example, there can be a big difference between storing a number in its binary form and as a string:

  • integer 666 is 00000010 10011010 in binary
  • string 666 is 00110110 00110110 00110110 in ASCII binary

So, we have 2 versus 3 bytes for such a relatively small and short number. Looking at it as a ratio, that’s around 33% more space taken up by the same data. Although encoding and compression can reduce these numbers, the raw data savings are still fairly obvious.

In other words, most often, it’s much more beneficial to use the [int]eger instead of a char or string data type when it comes to storing numbers.

Importantly, within Bash, all variables are basically strings by default:

$ var=666+1
$ echo $var

Still, there are cases when their interpretation matters.

3. Shell Numbers

Despite the fact that Bash variables are stored as strings, how they are interpreted depends on the context. Bash has very specific rules and syntax for shell arithmetic.

3.1. declare -i

First off, we have the declare builtin and its [-i]nteger attribute:

$ declare -i var=555+111
$ echo $var

Here, we see the 555+111 calculation results in 666.

In fact, once a variable has been [declare]d this way, any assignment to that variable is interpreted as an arithmetic expression:

$ var=$var+1
$ echo $var

Moreover, incorrect expressions can result in errors:

$ var=$var+1.0
-bash: 667+1.0: syntax error: invalid arithmetic operator (error token is ".0")
$ var=string
$ echo $var

Since Bash doesn’t deal with floating-point numbers directly, it can’t evaluate .0. Meanwhile, a string, i.e., incorrect type, assignment results in a value of 0.

To drop the type of a variable after we declare it with -i, we either need to re[declare] or unset it.

The declare builtin and its functionality is one of the closest features to type safety that Bash has. Still, it’s only in the form of post-factum compensatory corrections.

3.2. let

The let builtin also evaluates arithmetic expressions when assigning values:

$ let var=666+1
$ echo $var

However, unlike declare, let performs arithmetic evaluation for that particular assignment:

$ var=666+1
$ echo $var

As we can see, future assignments are treated as usual without needing to unset or redeclare $var. Importantly, let returns an exit status.

3.3. Arithmetic Expansion

To use arithmetic in any context, we can employ the $(()) arithmetic expansion syntax:

$ var=$((666+1))
$ echo $var

Similar to let, this doesn’t change the future behavior of the variable.

3.4. External Commands

Both expr and bc attempt to evaluate their arguments as numbers:

$ var=$(bc <<< '666+1')
$ echo $var
$ var=$(expr $var + 1)

The bc command is a requirement to build the Linux kernel, while expr is in the coreutils package and part of POSIX.

At this point, we have a solid understanding of the numeric context in the shell.

4. The test ([) Command

After getting to know data types and their relation to the shell, let’s understand the test ([) command a bit better.

4.1. Basic Test Syntax

In general, we can just use test directly:

$ test -x script.sh
$ echo $?

Here, we check whether the given script.sh file is e[x]ecutable. The non-zero exit status tells us it isn’t.

An alternative way to write the same is by beginning with [ and ending with ]:

$ [ -x script.sh ]
$ echo $?

In practice, the builtin is rarely used like this directly in the shell.

4.2. if-else

Instead, we commonly see [] as part of an if statement:

$ if [ -x script.sh ]; then
  echo 'script.sh is executable'
  echo 'script.sh isn't executable'
script.sh isn't executable

In fact, we can do the same with the test synonym just like any other command:

$if test -x script.sh; then echo 'script.sh is executable'; fi
script.sh is executable

In both cases, we decide on an action based on the exit status of test.

4.3. General Tests

First, let’s check the common syntax via some basic tests:

| Test                       | Meaning                                   |
| ( EXPRESSION )             | EXPRESSION is true                        |
| ! EXPRESSION               | EXPRESSION is false                       |

In this case, we see the logical AND and OR, as well as the negation operator !.

4.4. File Tests

There are a number of options for file tests with [.

To begin with, we can compare modification dates, device, and inode numbers:

| Test            | Meaning                                                |
| FILE1 -ef FILE2 | FILE1 and FILE2 have the same device and inode numbers |
| FILE1 -nt FILE2 | FILE1 is newer (modification date) than FILE2          |
| FILE1 -ot FILE2 | FILE1 is older than FILE2                              |
| -t FD           | file descriptor FD is opened on a [t]erminal           |

In addition, there are file type, permission, and attribute tests:

| Test    | Meaning                               |
| -b FILE | [b]lock special                       |
| -c FILE | [c]haracter special                   |
| -d FILE | [d]irectory                           |
| -e FILE | [e]xists                              |
| -f FILE | regular [f]ile                        |
| -g FILE | set-[g]roup-ID                        |
| -G FILE | owned by the effective [G]roup ID     |
| -h FILE | symbolic not [h]ard link (same as -L) |
| -k FILE | stic[k]y bit set                      |
| -L FILE | symbolic [L]ink (same as -h)          |
| -N FILE | [N]ot same (modified since last read) |
| -O FILE | [O]wned by the effective user ID      |
| -p FILE | named [p]ipe                          |
| -r FILE | user has [r]ead access                |
| -s FILE | [s]ize greater than zero              |
| -S FILE | [S]ocket                              |
| -u FILE | set-[u]ser-ID bit set                 |
| -w FILE | user has [w]rite access               |
| -x FILE | user has e[x]ecute (or search) access |

Notably, all switches in the last table check and verify that FILE exists, i.e., imply -e.

4.5. Comparisons

When it comes to string comparison, test uses the usual = and != operators. In addition, -z tests for the NULL string, but we can also check for [-n]on-NULL values.

On the other hand, integer comparisons have two-letter operators. Again, Bash doesn’t support floating-point operations.

Let’s summarize the comparisons of strings and integers within a single table:

| Comparsion                | String                | Integer               |
| [eq]ual                   | STRING1 [=]= STRING2  | INTEGER1 -eq INTEGER2 |
| [n]ot [e]qual             | STRING1 != STRING2    | INTEGER1 -ne INTEGER2 |
| [g]reater than or [e]qual | /                     | INTEGER1 -ge INTEGER2 |
| [g]reater [t]han          | /                     | INTEGER1 -gt INTEGER2 |
| [l]ess than or [e]qual    | /                     | INTEGER1 -le INTEGER2 |
| [l]ess [t]han             | /                     | INTEGER1 -lt INTEGER2 |
| equal to NULL ([z]ero)    | -z STRING             | /                     |
| [n]ot equal to NULL       | STRING [OR] -n STRING | /                     |

Importantly, even if we supply a literal number, it can be within quotes for both -eq and =:

$ if [ "666" -eq 666 ]; then echo 'same'; fi
$ if [ "666" = 666 ]; then echo 'same'; fi

Importantly, while test can diverge from the POSIX standard with comparisons like ==, we can use sh or a service like ShellCheck to ensure compliance, if necessary.

Considering this, let’s move on to some related Bash-specific features.

5. Bash Extensions With [[

Unlike the test ([) command, [[ is a keyword in Bash. Also, although it bears some resemblance to [, [[ isn’t POSIX standard.

The same -xx integer equality and string equality operators work with both [[ and [.

However, there are a number of differences between the two constructs:

  • [[]] normally only returns 0 or 1 with 2 being a syntax error
  • words between [[ and ]] do not get split or expanded
  • only double quote expansions get performed in the context of [[]]
  • == and != consider the righthand operand as a pattern for pattern matching
  • =~ treats the righthand operand as a POSIX extended regular expression with quoted parts being matched literally
  • -a is &&, -o is ||

To clarify, let’s list the double quote preprocessing that Bash does:

  • tilde expansion
  • parameter expansion
  • arithmetic expansion
  • variable expansion
  • command substitution
  • process substitution
  • quote removal

In summary, we have only partial upgrades to the equivalence comparisons and added features in terms of logical operators and regex.

6. Summary

In this article, we talked about data types and the test or [] builtin of Bash.

In conclusion, depending on our needs for compliance with POSIX, we can use the basic or extended Bash feature set for matching and comparisons.

Comments are closed on this article!