Why this matters
The [ command from the last lesson works everywhere, but it is fragile: forget a pair of quotes and a file name with a space breaks your script. bash (and zsh and ksh) offer a better tool, [[ ... ]], which removes most of those traps and adds pattern matching, so you can ask "does this name end in .log?" directly. In bash scripts, prefer [[; know [ for portable sh scripts and for reading other people's code.
A keyword, not a command
Compare:
$ type [
[ is a shell builtin
$ type [[
[[ is a shell keyword
A builtin is a command that happens to live inside bash: bash parses the line, expands every word, then calls the builtin with the resulting arguments, exactly as it would call /usr/bin/[. A keyword is part of the grammar, like if, then, while and fi. bash recognizes [[ ... ]] while parsing, before expansion, and evaluates the expression itself.
That one difference has practical consequences:
[ $f = x ] [[ $f == x ]]
1. bash expands $f -> "my file" 1. bash parses [[ ]] and sees: operand OP operand
2. word splitting -> my | file 2. expands $f -> "my file" (no splitting, no globbing)
3. runs: [ my file = x ] 3. compares "my file" with x
4. [ : too many arguments
Inside [[ ]]:
- variables are not word-split and not glob-expanded, so
[[ -f $file ]]is safe even when$fileis empty or contains spaces; &&,||,!and parentheses work as logical operators inside the brackets;<and>compare strings (no redirection happens);==and!=do pattern matching when the right side is unquoted;=~matches a regular expression (covered in module 15).
Quoting variables inside [[ ]] is harmless, and many people keep doing it out of habit. The exception is the right side of ==, where quoting changes the meaning (below).
Pattern matching with ==
On the right of == or !=, an unquoted word is a glob pattern, the same language you use for file names:
| Pattern | Matches |
|---|---|
* | any string, including empty |
? | exactly one character |
[abc] | one of a, b, c |
[0-9] | one digit |
[!0-9] | one character that is not a digit |
$ f="backup-2026-11-01.tar.gz"
$ [[ $f == backup-* ]] && echo "a backup"
a backup
$ [[ $f == *.tar.gz ]] && echo "gzipped tarball"
gzipped tarball
$ [[ $f == *-[0-9][0-9][0-9][0-9]-* ]] && echo "has a year"
has a year
$ [[ $f == "*.tar.gz" ]] && echo match || echo "no match"
no match
The last one quoted the pattern, so it compared against the literal text *.tar.gz. That is the tool you need when the pattern comes from a variable and you want literal comparison: [[ $answer == "$expected" ]].
Important: this matches the string, not files on disk. [[ $f == *.txt ]] never looks in any directory; it just checks how $f ends.
Logical operators inside
if [[ -f $config && -r $config ]]; then
echo "config is a readable file"
fi
if [[ $answer == y || $answer == yes ]]; then
echo "confirmed"
fi
if [[ ! -d $dir ]]; then
echo "$dir is not a directory"
fi
if [[ ( $a == 1 || $a == 2 ) && $b == x ]]; then
echo "grouped"
fi
&& binds tighter than || here, as in most languages. With [ you would need separate commands: [ -f "$config" ] && [ -r "$config" ].
String ordering
< and > inside [[ ]] compare strings in the current locale's sort order:
$ [[ apple < banana ]] && echo yes
yes
$ [[ 10 < 9 ]] && echo "string order!"
string order!
That second line is correct but probably not what you meant: as strings, "10" comes before "9". For numbers, use -lt (works inside [[ ]] too) or arithmetic (( )) from the next lesson.
A worked example
Open the shell for this lesson and try a little "is this a safe name" checker, checkname.sh:
#!/bin/bash
name=$1
if [[ -z $name ]]; then
echo "empty name"
exit 2
elif [[ $name == -* ]]; then
echo "starts with a dash: commands may read it as an option"
elif [[ $name == *" "* ]]; then
echo "contains a space: remember to quote it"
elif [[ $name == *[!A-Za-z0-9._-]* ]]; then
echo "contains an unusual character"
else
echo "plain name"
fi
$ bash checkname.sh report.txt
plain name
$ bash checkname.sh "my file"
contains a space: remember to quote it
$ bash checkname.sh -rf
starts with a dash: commands may read it as an option
$ bash checkname.sh 'what?.txt'
contains an unusual character
$ bash checkname.sh ""; echo $?
empty name
2
Note *" "*: the quoted space is literal while the stars stay patterns. You can mix quoted and unquoted parts in one pattern. [!A-Za-z0-9._-] is a bracket expression meaning "any character not in this set"; the - is last so it is literal.
[ or [[ ?
[ ] / test | [[ ]] | |
|---|---|---|
| What it is | command (builtin) | bash keyword |
Portable to sh | yes | no (bash, ksh, zsh) |
Word splitting of $var | yes: quote everything | no |
Patterns with == | no | yes |
&&, || inside | no (use between tests) | yes |
Regex =~ | no | yes |
In bash scripts use [[ ]]. Use [ ] in #!/bin/sh scripts, which module 16 covers.
Common mistakes
- Quoting the pattern by accident.
[[ $f == "*.txt" ]]is a literal comparison and almost always false. Leave patterns unquoted. - Expecting
==to look at files. It compares strings. To test existence use-e,-f. - Still needing spaces.
[[$x == 1]]givesbash: [[1: command not found. Keywords and operands are separate words. - Using
<for numbers.[[ 10 < 9 ]]is true (string order). Use-ltor(( )). - Using
[[in a#!/bin/shscript. On Debian,shis dash, which reports[[: not found. - Forgetting that
=~and==differ.==takes a glob (*.txt);=~takes a regular expression (\.txt$). Mixing them up gives silent mismatches.
Exercises
The code exercise is checked with several names, including one with a space and one that tests whether your branches are in the right order. The task has a downloads folder with tricky names like old.log.gz; after your script runs, the check looks at where every file ended up.
Recap
[[ ]]is a bash keyword: variables inside are not split or globbed, so empty values and spaces are safe.==/!=with an unquoted right side do glob pattern matching on strings; quote to compare literally.&&,||,!and parentheses work inside[[ ]].</>compare strings, not numbers.- Prefer
[[ ]]in bash scripts; use[ ]for portablesh.
Going further: read the [[ section of help [[. Try shopt -s nocasematch and then [[ ABC == abc ]] && echo same: that option makes pattern matching case-insensitive (turn it off again with shopt -u nocasematch).