Conditionals and Tests · about 40 min

[[ ]]: strings, patterns and files

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 [[ ]]:

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:

PatternMatches
*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 iscommand (builtin)bash keyword
Portable to shyesno (bash, ksh, zsh)
Word splitting of $varyes: quote everythingno
Patterns with ==noyes
&&, || insideno (use between tests)yes
Regex =~noyes

In bash scripts use [[ ]]. Use [ ] in #!/bin/sh scripts, which module 16 covers.

Common mistakes

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

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

Exercises

Exercise 1 · quiz

Why no quotes needed?

With file="my notes.txt", [ -f $file ] fails with too many arguments, but [[ -f $file ]] works. Why?

Answer
  • [[ is a shell keyword parsed by bash itself, so variables inside it are not word-split or glob-expanded.

[ is a command: bash expands its arguments fully (including word splitting and globbing) before [ ever runs. [[ is part of bash's grammar, like if and while. bash sees the whole [[ ... ]] before expansion, knows which words are operands, and expands them without splitting or globbing. The right-hand side of == is the one place where quoting still changes meaning: unquoted it is a pattern, quoted it is a literal string.

Exercise 2 · quiz

Pattern or literal?

With f="report.txt", which of these are true? Select all that apply.

Answer
  • [[ $f == *.txt ]]

  • [[ $f == report.??? ]]

  • [[ $f != *.csv ]]

On the right of == and !=, an unquoted word is a glob pattern: * matches anything, ? one character. Quoting it, "*.txt", makes it a literal string, and the file name is not literally *.txt. Matching is case-sensitive, so R* does not match report.txt. Note this is pattern matching against the string; no files are looked up.

Exercise 3 · write code

Classify file names

Write a script that takes one file name as an argument (the file need not exist; you are only looking at the name) and prints its category:

Matching is case-sensitive. Use [[ ... == pattern ]], combining patterns with ||. Exit with status 0.

File: script.sh

Starting point

#!/bin/bash
name=$1

Example with arguments holiday photo.jpg

expected output

image

Example with arguments logo.png

expected output

image
Hint 1

[[ $name == *.txt ]] is true when the name ends in .txt. Do not quote the pattern.

Hint 2

Inside [[ ]] you can write ||: [[ $name == *.txt || $name == *.md ]].

Reference solution
#!/bin/bash
name=$1
if [[ $name == *.jpg || $name == *.jpeg || $name == *.png ]]; then
    echo image
elif [[ $name == *.txt || $name == *.md ]]; then
    echo text
elif [[ $name == .* ]]; then
    echo hidden
else
    echo other
fi

Exercise 4 · task in a shell

Sort out a downloads folder

/work/downloads holds a mix of files, some with spaces in their names. Write a short script /work/sort.sh that, for every file in downloads, moves files ending in .pdf into /work/docs and files ending in .log into /work/logs, leaving everything else where it is. Create the two directories first. Then run it with bash sort.sh.

You may use this loop skeleton (loops are the next module; the line for f in downloads/* runs the body once for each file, with $f set to its path):

for f in downloads/*; do
    if [[ ... ]]; then
        ...
    fi
done
Hint 1

[[ $f == *.pdf ]] tests the name. Inside mv, quote: mv "$f" docs/, because mv is an ordinary command.

Hint 2

Put mkdir -p docs logs at the top of the script, then the loop with an if/elif.

Reference solution
cat > sort.sh <<'EOF'
#!/bin/bash
mkdir -p docs logs
for f in downloads/*; do
    if [[ $f == *.pdf ]]; then
        mv "$f" docs/
    elif [[ $f == *.log ]]; then
        mv "$f" logs/
    fi
done
EOF
bash sort.sh