SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

Bash Globbing: Wildcards and Extglob

Bash globbing explained: how the shell expands wildcards into filenames before a command runs, plus nullglob, dotglob, globstar and extglob negation.

What bash globbing does before your command runs

Bash globbing is the shell turning a pattern such as *.log into a sorted list of real filenames, before the command starts. rm *.log never hands rm a pattern. Bash reads the directory, keeps the names that match, sorts them, and then runs rm with each name as a separate argument. The rm program cannot tell that a pattern was ever involved.

Almost everything surprising about globbing follows from that one fact. The matching is done by the shell, against the files that exist at that moment, and the command sees only the finished list. The formal name for this is pathname expansion. Globbing is the everyday word, and both mean the same thing.

Build a small tree to practise on

Every example below runs against the same files. Create them in a temporary directory, so nothing you care about is nearby and cleanup is one command.

lab=$(mktemp -d)
cd "$lab"
mkdir -p logs archive/2025 archive/2026
touch app.log app.log.1 app.log.2 app.log.10 error.log debug.LOG
touch notes.txt notes.md README 'weekly report.log'
touch .env .apprc
touch logs/nginx.log logs/nginx.log.1
touch archive/2025/dec.log archive/2026/jan.log

mktemp -d creates a fresh directory and returns its path, and $(...) captures that path into the variable lab. If that syntax is new to you, how command substitution captures a command's output explains it in full.

Look at what you built, and check your shell, because one note at the end depends on the version:

ls -a
bash --version

Everything here works on bash 4.0 and later. The globskipdots note near the end applies to bash 5.2 and later, which is what current Ubuntu and Debian releases ship as of August 2026.

The preview command used throughout is printf '%s\n' PATTERN. It prints one match per line and changes nothing on disk, so it is safe to run against any pattern you are unsure about.

The three wildcards: star, question mark and brackets

  • * matches any run of characters, including an empty one.
  • ? matches exactly one character.
  • [...] matches exactly one character taken from the set inside the brackets.
printf '%s\n' *.log
printf '%s\n' app.log.?
printf '%s\n' app.log.[0-9]
printf '%s\n' notes.*
printf '%s\n' *

Compare the second line against the file list you created. ? stands for one character and nothing more, so a two digit suffix falls outside that pattern. Widen it to app.log.[0-9]* and run it again to see the difference.

Two rules apply to all three wildcards. No wildcard ever matches a /, so a pattern stays inside one directory level. And a leading . has to be typed literally, so a pattern that begins with * skips every hidden name. That second rule is why rm * leaves your dotfiles alone.

Glob results arrive sorted, using the collation order of the current locale, which is a character by character text sort. A suffix of 10 therefore sorts before a suffix of 2, because the comparison never reads either one as a number. Run printf '%s\n' app.log.* and then ls -v app.log.* and compare the two: ls -v sorts embedded numbers by value, and sort -V does the same job in a pipeline.

Character classes and ranges

Inside the brackets you can write a list, a range, or a negation.

  • [ch] matches one character, either c or h.
  • [a-f] matches one character in the range a to f.
  • [!0-9] matches one character that is not a digit. [^0-9] means the same thing.
printf '%s\n' [dn]*
printf '%s\n' *.[A-Z]*
printf '%s\n' [A-Z]*

Ranges are the part that breaks between machines. A range is resolved with the collation order of the current locale rather than plain ASCII, so on some systems [a-z] also matches uppercase letters. Bash has a globasciiranges option that forces plain ASCII order, and recent builds enable it by default, so check yours with shopt globasciiranges rather than assuming. A script that has to match the same way everywhere can pin the order itself by setting LC_ALL=C on a line of its own, near the top, which puts every range back on plain byte order.

That assignment has to be a command in its own right. Written as a prefix, LC_ALL=C ls [a-z]* sets the locale for ls and nothing else, and the shell has already expanded the pattern in its own locale before ls ever starts, which is the rule from the first section showing up again.

Brace expansion is not globbing

Braces look like a wildcard and behave nothing like one. Brace expansion runs much earlier than globbing, and it never looks at the disk.

echo file{1,2,3}.txt
echo {01..10}
echo {a..e}
echo {0..20..5}

None of those files exist, and the expansion happens anyway. That is the whole difference: a glob asks the filesystem a question, while a brace only generates text. It is also why braces are the right tool for creating things rather than finding them.

mkdir -p site/{css,js,img}
cp app.log{,.bak}
touch report-{2025,2026}-{01,02}.csv

cp app.log{,.bak} expands into two words, app.log and app.log.bak, because the empty item before the comma reproduces the original text. {01..10} keeps its zero padding, because a leading zero on either endpoint makes bash pad every generated number to the same width.

Expansion order also explains a failure people hit often. Bash expands braces first, then variables, and does pathname expansion last.

n=5
echo {1..$n}

Brace expansion has already finished by the time $n becomes a number, so no range is ever built. Use seq 1 "$n", or a C-style loop written as for ((i=1; i<=n; i++)).

What happens when a pattern matches nothing

This is the first failure mode, and it catches everyone once. By default bash leaves an unmatched pattern completely alone. The word reaches the command exactly as you typed it, so the command receives the characters *, ., t, m and p and tries to treat them as a filename.

printf '%s\n' *.tmp

That is harmless with printf. It is not harmless with a command that creates things. touch *.tmp in a directory with no .tmp files creates a file whose name is literally *.tmp, and deleting it afterwards needs quoting so the shell does not expand it again: rm -- '*.tmp'.

Two shell options change the rule. Try each one against a pattern that matches nothing, then unset it.

shopt -s nullglob
printf '%s\n' *.tmp
shopt -u nullglob

shopt -s failglob
printf '%s\n' *.tmp
shopt -u failglob

nullglob makes an unmatched pattern expand to nothing at all, so the command runs with fewer arguments than you wrote. That is what you want around a loop. With nullglob off, for f in *.tmp runs its body once with f holding the literal pattern, which is a bug in almost every script that does it. With nullglob on, the body runs zero times.

nullglob carries its own hazard, because a command stripped of all its arguments still runs. ls *.nope becomes a bare ls, which lists the whole directory. grep -l needle *.nope becomes grep -l needle, which has no file to read, so it waits on standard input and looks like it has frozen. Turn nullglob on around the loop that needs it, and off again after.

failglob takes the other route: an unmatched pattern is an error, bash reports it, and the command never runs at all. That is a good setting for an interactive shell, because it stops a mistyped pattern from being handed to rm as a literal name.

Hidden files: dotglob

shopt -s dotglob
printf '%s\n' *
shopt -u dotglob

Run that block and compare the listing against the plain printf '%s\n' * from earlier. dotglob removes the leading dot rule, so * matches hidden names too. The entries . and .. stay excluded under dotglob, always.

Without dotglob, .* is the usual way to reach hidden files, and it used to be dangerous. On bash before 5.2, .* also matched . and .., so a recursive command such as chmod -R 755 .* walked straight into the parent directory. Bash 5.2 added the globskipdots option, enabled by default, which keeps . and .. out of every expansion. Run shopt globskipdots before you rely on it, because an older box will not have it.

globstar: matching all the way down the tree

shopt -s globstar
printf '%s\n' **/*.log
printf '%s\n' **/
shopt -u globstar

With globstar set, a ** that forms a whole path component matches files and directories at any depth, including zero levels down, so **/*.log covers the current directory as well as logs/ and everything under archive/. A **/ with the trailing slash matches directories only, which makes it a fast way to see the shape of a tree.

Without globstar set, ** behaves as an ordinary * and stays in one directory. Run the same pattern with the option off and compare: a recursive pattern copied from somewhere else that returns far less than you expected is usually this. globstar is off by default in every bash.

One limit worth knowing: ** does not follow symbolic links to directories. A tree stitched together with symlinks needs find -L instead.

nocaseglob, for names that disagree about case

shopt -s nocaseglob
printf '%s\n' *.log
shopt -u nocaseglob

nocaseglob makes filename matching ignore case, so an uppercase .LOG suffix matches a lowercase .log pattern. Compare this listing with the one from the first section. Turn the option off again straight away, because left on it changes matching for every command that follows in that shell, and that is a confusing thing to debug an hour later.

nocaseglob applies to filename expansion only. The related option for pattern matching inside a case statement or a double bracket test is nocasematch, and the two are set separately.

extglob: the patterns that can say "not this"

Extended patterns are off by default. Turn them on, on a line of their own:

shopt -s extglob

There are five forms, each taking a list of patterns separated by |:

  • ?(list) matches zero or one occurrence.
  • *(list) matches zero or more.
  • +(list) matches one or more.
  • @(list) matches exactly one of the alternatives.
  • !(list) matches anything that does not match any alternative.

An extended pattern has to be read by a shell that already has the option on, so the batch below goes in a small script started with bash -O extglob. The -O flag sets a shopt option before the new shell reads a single line of the file, which means every pattern in it parses correctly. The last paragraph of this section is the reason that ordering matters.

cat > patterns.sh <<'EOF'
printf '%s\n' @(app|error).log
printf '%s\n' app.log.+([0-9])
printf '%s\n' *.@(md|txt)
printf '%s\n' !(*.log)
printf '%s\n' !(*.log|*.md|README)
EOF
bash -O extglob patterns.sh

+([0-9]) means one or more digits, so it covers a numbered suffix of any length. That is the fix for the ? limit from earlier, and it is worth running both patterns back to back to see it.

The negation form is the answer to "everything except", and it is the reason to enable extglob at all. Two things about it surprise people.

It matches directories as well as files, so ls !(*.log) lists the contents of any directory in the result instead of the directory name. Use ls -d, or the printf preview, to see the names themselves.

It also obeys the dot rule like every other glob, so !(*.log) covers every visible name that does not end in .log. Hidden names need dotglob set as well.

The ! in !(...) is glob negation. It has no connection to history expansion and the bang character, which is a separate feature that runs at a different stage.

One more trap, and this one happens at parse time. Bash parses a complete command before it runs any part of it, so enabling extglob and using an extended pattern on the same line fails: the pattern is parsed while the option is still off. Keep shopt -s extglob on its own line, before the patterns, and near the top of a script. A function body is parsed when the function is defined, so the option has to be on before the definition, not before the call. Starting the shell with bash -O extglob, the way the batch above does, settles the ordering before any parsing begins.

The shell expands the pattern, the command does not

This is the second failure mode, and it explains a whole family of bugs.

grep -l needle *.log
find . -name '*.log'
find . -name *.log

The first line lets bash do the matching, and grep receives a list of filenames. The second quotes the pattern, so find receives the five characters *.log and does its own matching, at every depth below the starting point. The third line is the bug: bash expands the pattern first, against the current directory only, so find is told to look for one specific name. In a directory with a single match it silently searches for the wrong thing. With two or more matches, find reports a usage error, because the extra names arrive in the place where it expects an expression.

The rule is short. If the pattern is meant for the command, quote it. If it is meant for the shell, leave it bare. The same split governs --exclude patterns in tar and rsync, and the --include filters in grep.

Globs are also not regular expressions, even though they share characters. In a glob, * means any run of characters. In a regular expression, * means zero or more of whatever came before it, so grep '*.log' asks for something quite different from what it looks like.

Variables follow the same order of operations. Globbing runs after variable expansion, so an unquoted variable holding a pattern gets expanded against the directory.

pat='*.log'
printf '%s\n' $pat
printf '%s\n' "$pat"

Unquoted, bash globs the result of the variable. Quoted, the pattern stays literal. There is a related consequence you should rely on: the words produced by globbing are never split again on spaces, so for f in *.log handles a filename containing a space as one name, while for f in $(ls *.log) breaks that name into two words. Loop over the glob directly.

Preview a destructive pattern before you run it

Never let rm be the first command you try a new pattern with. Run the pattern with something harmless, read the list, then change only the command at the front of the line.

target='!(*.log)'
printf '%s\n' $target
ls -ld -- $target
rm -- $target

Writing the pattern into target once stops the three lines from drifting apart, and $target is left unquoted on purpose, because that is what lets bash expand it. printf '%s\n' prints one name per line and touches nothing. ls -ld shows each entry itself rather than listing the contents of directories, and the mode column marks which entries are directories, which is the thing to check before any recursive delete. Reading the drwxr-xr-x permission bits covers that column.

Recall the previous line with the up arrow and edit only the command, so the pattern stays identical. Retyping a pattern from memory is where the mistake creeps in.

Put -- before the pattern. It marks the end of options, so a filename that begins with - is treated as a name instead of a flag.

If you want globbing switched off for a stretch of a script, set -f disables it and set +f turns it back on.

This habit matters because rm has no undo. Recovering files deleted with rm -rf is slow and usually incomplete, so a preview costs far less than the alternative.

Clean up the practice directory when you are finished. Print the path and read it first, because rm -rf pointed at the wrong variable is exactly the accident this section is about.

cd ~
echo "$lab"
rm -rf -- "$lab"

FAQ

Why does find . -name *.log find the wrong files?

Because bash expands *.log before find ever starts. The shell matches the pattern against the current directory and passes the resulting filename to find, which then searches for that one name at every depth. If two or more files match, find receives extra arguments where it expects an expression and reports a usage error. Quote the pattern as find . -name '*.log' so that find does the matching itself, with its own rules and its own recursion.

What does it mean when my pattern appears as literal text?

It means nothing matched. By default bash leaves an unmatched pattern untouched and passes the raw characters to the command, which then treats them as a filename. Set shopt -s failglob to turn an unmatched pattern into an error that stops the command, or shopt -s nullglob to make it expand to nothing. Under nullglob, check that the command still makes sense with no arguments at all, because ls *.nope becomes a bare ls and lists the whole directory.

How do I match every file except one pattern in bash?

Enable extended patterns with shopt -s extglob on a line of its own, then use the negation form. !(*.log) matches names that do not end in .log, and !(*.log|*.md) excludes both. Hidden files stay out of the result unless dotglob is set as well, and directories are included, so preview the pattern with printf '%s\n' !(*.log) before you hand it to rm.

Why does * skip hidden files?

A leading dot has to be matched literally, so * never matches a name that starts with one. Use .* to reach hidden names, or set shopt -s dotglob to fold them into every ordinary pattern. The entries . and .. are always excluded under dotglob, and bash 5.2 and later keep them out of .* as well through the globskipdots option, which is on by default.

Do I need globstar for ** to work?

Yes. Without shopt -s globstar, bash treats ** as an ordinary *, which matches inside one directory and stops there. With globstar on, a ** that forms a whole path component matches at any depth, so **/*.log reaches the current directory and every subdirectory below it, and **/ on its own matches directories only. Note that ** does not follow symbolic links to directories, so use find -L for a tree built out of symlinks.

#bash#globbing#shell#linux#scripting