Did you know that Unix systems have a binary whose name is a single symbol?

Go and look for it. Run ls /bin/? and behold:

$ ls /bin/?

Uh huh. [? The square bracket? That’s a program?!

But wait, it gets more interesting:

$ ls -li /bin/[ /bin/test
834 -rwxr-xr-x  2 root  wheel  35824 Jan 23 08:59 /bin/[
834 -rwxr-xr-x  2 root  wheel  35824 Jan 23 08:59 /bin/test

The two names, [ and test, point to the same binary1. But why? What are these?

The test program is what you use in the shell to evaluate an expression. You can use this to compare strings, compare numbers, and to check various conditions on files. If you have written a shell script—any shell script really—you certainly have used either of these variants.

The way this works is simple: the test program takes a bunch of arguments, evaluates the expression represented by them, and returns 0 if the expression is true or 1 if it is false. This then lets you do things like:

if test a = b; then
    echo "The two strings were the same! Oops!"

So why do we have two names for this helper tool? I haven’t been able to find the definitive answer, but my guess is simply: because the above “looks ugly”, and the “obvious” solution to make it look nicer is to introduce [ as a command. With it, you can express the same logic from above as:

if [ a = b ]; then
    echo "The two strings were the same! Oops!"

Yup. Exact same code as above. The only difference here is that the test binary checks its argv[0] to see if it’s invoked as test or as [. If the invocation happens to use the latter, then the program ensures that the last argument is the matching ] to keep things balanced.

With that, you can deduce that you don’t even need the conditional statement to use either of these commands and see what’s going on:

$ test a = a; echo $?
$ test a = b; echo $?
$ [ a = a ]; echo $?
$ [ a = b ]; echo $?

That’s right. The if statement we used in the previous examples just takes a command as its argument and runs it to get its exit code. (And with this, you can “guess” that true and false are… yup, yup… also helper binaries.)

To make things more confusing, though, pay attention to the following:

$ /bin/test a b
test: a: unexpected operator
$ test a b
dash: 2: test: a: unexpected operator

Why did we get different outputs there? Well… as it so happens, test and [ appear a lot in shell scripts. Invoking them as separate binaries would be very inefficient, so the vast majority of the shells implement these commands as built-ins too. You may get different behavior depending on whether you run the external binary or the builtin, which means you easily get different behavior across different shells. (And that’s true for many other things like the innocent-looking echo.)

So what about [[? This is a Bash extension and replaces the use of [. The key difference, however, is that [[ is guaranteed to be a builtin and therefore it can change, and it does change, the fundamental rules of the language within the expression it evaluates. To illustrate this, let’s look at an example with globs:

$ touch long-name
$ [ long* = long-name ] && echo match
$ [[ long* = long-name ]] && echo match

The first command shown here is an invocation of the [ tool, which may or may not be a builtin. No matter what, all arguments are subject to the regular shell expansion rules, so long* is matched against the directory contents, is then expanded to long-name, and thus the test succeeds. But, in contrast, [[ produces a different result because it treats the long* as a literal string, so all this is doing is comparing long* against long-name verbatim, and therefore failing.

What should you use, then? If you are writing a portable shell script (please do), then stick to [. You can also use test, but I don’t think that’s too common. But if you know your script is going to be Bash-specific anyway, you are probably better served by using [[ unconditionally and consistently, as it provides a lot of nice features (like regular expression matches via =~).

And now for the final lolz. I’ve said above that these are the commands you use to evaluate expressions… but the shell also has expressions of its own via the !, &&, and || operators—all of which work on command exit statuses. That is:

$ grep ^hello$ /usr/share/dict/words && grep ^bye$ /usr/share/dict/words
$ echo $?
$ grep ^tyop$ /usr/share/dict/words && grep ^bye$ /usr/share/dict/words
$ echo $?

Which means… that you can combine test expressions and shell expressions in one invocation:

if [ a = b ] || grep -q ^hello$ /usr/share/dict/words; then
  echo "test failed and grep succeeded"

You pick whether to be amused or horrified. I don’t know how exactly my coworker reacted when I hinted at this during a recent code review I did for them.

  1. /bin/[ and /bin/test are not necessarily hard links. They indeed are on NetBSD and I had assumed that was true on all systems… but a cursory look shows that macOS Catalina ships these as two separate copies of the same binary and Debian testing just has two different binaries. The POSIX spec does not require them to be links after all. ↩︎