skip to content

Globbing and Pattern Matching

Globs match filenames and are not regular expressions, and an unmatched glob is left as a literal string by default — which is why `for f in *.txt` can hand you the text "*.txt". extglob, globstar, and nullglob are the shopt switches that file down the sharp edges.

part ofBashoverview, primer and where to startread it →
on this pageshow

questions

5

In bash pathname expansion (globbing), what do the metacharacters `*`, `?`, `[abc]` and `[[:digit:]]` match, and why is the glob `*.txt` not the same pattern as the regular expression `*.txt`?

level: juniorimportance: must knowfreq 72%

answer

  1. the shell expands, the command never sees it
  2. whole name, one path component
  3. dot and slash are not matched by *
  4. asterisk repeats in regex, spans in glob
  5. POSIX class lives inside brackets

basics

~20 s

In globbing, * matches any run of characters, ? exactly one character, and [abc] or [[:digit:]] one character from a set; the pattern must match a whole filename. In a regex, * instead repeats the preceding item.

solid answer

~40 s

Globs are a filename-matching language, not regular expressions. Bash expands an unquoted word containing `*`, `?` or `[` by matching it against existing filenames and replacing the word with the sorted list of matches, so the command never sees the pattern at all. `*` matches any string including the empty one, `?` matches exactly one character, `[abc]` matches one character from the set, `[a-z]` a range, `[!abc]` anything not in the set, and `[[:digit:]]` a POSIX character class inside the brackets. Neither `*` nor `?` crosses a `/`, and neither matches a leading dot. The pattern must match the entire path component, so `*.txt` matches `notes.txt` but not `notes.txt.bak`. As a regex the same characters mean something else entirely — the regex equivalent of the glob `*.txt` is `^.*\.txt$`.

code

bash · 8 lines
bash
cd "$(mktemp -d)"
touch notes.txt notes.txt.bak report2.txt report11.txt .hidden.txt

echo *.txt            # notes.txt report11.txt report2.txt
echo ?otes.txt        # notes.txt
echo report[0-9].txt  # report2.txt   (one digit only)
echo *                # hidden files are not listed
echo '*.txt'          # quoted: the literal pattern, no expansion

go deeper

for a junior

Be able to say what *, ? and [abc] match in a filename and that the shell expands them before the command runs. Know that quoting a pattern disables the expansion.

for a middle

Explain that matching is per path component and anchored at both ends, that * skips dot-files and never crosses /, and translate a glob into its regex equivalent on demand.

for a senior

Show that you know where the two languages get mixed up in real scripts — unquoted patterns passed to find or grep, locale-dependent ranges and sort order differing between a laptop and a CI image.

for a principal

Frame it as an interface question: patterns crossing a tool boundary need to be quoted and owned by exactly one matcher. Set expectations in review that scripts pin LC_ALL when ordering or ranges affect behaviour.

## The shell does the matching, not the command The single most useful thing to understand about globs is *who* expands them. When you type `ls *.txt`, `ls` never receives a `*`. Bash performs **pathname expansion** on the unquoted word `*.txt`, looks in the directory for names that match, and replaces the word with the sorted list of matching pathnames before `ls` is executed. `ls` sees `ls notes.txt report2.txt`. This has two immediate consequences. First, a glob can only produce names that **actually exist on disk** — pathname expansion is a directory lookup, not string generation. Second, quoting turns the pattern off: `ls "*.txt"` and `ls '*.txt'` pass a literal asterisk to `ls`, which then almost certainly reports that no such file exists. The same pattern syntax is reused elsewhere in the shell for pure string matching without touching the filesystem (notably `case` and `[[ ]]`, which are covered separately), but the metacharacters below mean the same thing everywhere. ## The metacharacters ```bash * # any string, including the empty string ? # exactly one character [abc] # one character: a, b or c [a-z] # one character in the locale's collation range [!abc] # one character that is NOT a, b or c ([^abc] also works in bash) [[:digit:]] # one character in a POSIX class, inside the brackets ``` The doubled brackets in `[[:digit:]]` confuse people constantly. The class itself is written `[:digit:]`, and it is only meaningful **inside** a bracket expression, so the outer `[ ]` is the bracket expression and the inner `[: :]` is the class. That is why `[[:digit:][:upper:]]` — one character that is a digit or an uppercase letter — is legal. Other classes include `[:alpha:]`, `[:alnum:]`, `[:space:]`, `[:lower:]` and `[:punct:]`. ## Whole-component matching A glob is matched against one **path component** at a time, and the whole component must match. Neither `*` nor `?` will match a `/`, which is why `*.js` never reaches into a subdirectory and why you write `src/*/*.js` to go exactly two levels down. Because the match is anchored at both ends of the component, `*.txt` matches `notes.txt` and rejects `notes.txt.bak`; there is no notion of "contains" the way `grep` has. A leading dot is also special: `*` does not match a name that starts with `.`, so hidden files are invisible to plain globs unless the pattern itself begins with a dot or the `dotglob` shell option is enabled. ## Glob versus regex, character by character | Character | As a glob | As a regex | |---|---|---| | `*` | any run of characters | repeat the **previous** item zero or more times | | `?` | exactly one character | make the previous item optional | | `.` | a literal dot | any single character | | `+` | a literal plus | one or more of the previous item | | `[abc]` | one of a, b, c | one of a, b, c (the one they share) | | anchoring | implicit, whole component | none; a regex matches a substring by default | So reading `*.txt` as a regular expression is nonsense: `.` would mean "any character" and the leading `*` has no preceding item to repeat. Translating in the other direction, the glob `*.txt` becomes the regex `^.*\.txt$` and the glob `report?.csv` becomes `^report.\.csv$`. The practical fallout is mixing the two by accident. `grep *.log` is a bug: the shell expands `*.log` into filenames first, and `grep` treats the first one as its pattern. `find . -name '*.log'` needs the quotes precisely so the shell does **not** expand the glob — `find` does its own glob matching internally. `ls '[0-9]*'` and `grep '[0-9]*'` look identical and behave completely differently, because in the regex the `*` applies to the bracket. ## Locale, ordering and ranges Two details bite in production. Expansion results are sorted using the current locale's collation order (`LC_COLLATE`), not raw byte order, so a script that depends on file ordering can behave differently on a developer laptop and a CI container. And range expressions like `[a-z]` are collation-based too: in many UTF-8 locales the collation interleaves cases (a, A, b, B, …), so `[a-z]` can match uppercase letters. Use the POSIX classes — `[[:lower:]]`, `[[:digit:]]` — when you mean a definite set. ## What happens when nothing matches By default bash leaves an unmatched pattern in place as a literal word, which is its own well-known source of bugs and has its own shell options to change it. Remember the default so you are never surprised when a command receives an argument with an asterisk in it.

  • Why does `grep *.log` behave unpredictably, and what did the author almost certainly mean?
    The shell expands `*.log` before `grep` runs, so grep takes the first matching filename as its search pattern and the rest as files to search. If nothing matches, grep gets the literal `*.log` as a pattern instead. The author meant a quoted pattern plus explicit files, for example `grep 'ERROR' *.log`.
  • How would you match exactly the files `report1.csv` through `report9.csv` and nothing else?
    `report[0-9].csv`. The bracket expression matches exactly one character, so `report11.csv` is excluded, unlike `report*.csv` which would match it. If you need one or more digits you either enable extended globs or filter the names in a loop.
  • Why can `[a-z]` match an uppercase letter, and what should you write instead?
    Range endpoints are interpreted using the locale's collation order, and many UTF-8 locales interleave cases as a, A, b, B and so on, so `[a-z]` can sweep in uppercase letters. Use the POSIX class `[[:lower:]]`, which is defined by character type rather than collation position, or force `LC_ALL=C` for byte ordering.

A glob is a fill-in-the-blank form matched against every name in one folder; a regex is a search engine that hunts for a matching stretch of text anywhere inside a string.

saying these in an interview costs you the question

  • Calling globs regular expressions with different syntax
  • Thinking `*.txt` matches anywhere in the name
  • Expecting `*` to match a leading dot or a slash
  • Believing the command itself does the glob matching
  • Writing `find . -name *.log` without quoting the pattern

context

open as a page

A bash script runs `for f in *.log; do process "$f"; done` in a directory that contains no .log files, and `process` is called once with the literal string `*.log`. Why does bash behave that way, and what are the correct fixes?

level: middleimportance: must knowfreq 62%

basics

~20 s

By default bash leaves a pattern that matches nothing unchanged, so the word *.log survives as a literal and the loop runs once. Fix it with shopt -s nullglob, shopt -s failglob, or an existence guard inside the loop.

open as a page

Bash's extended glob operators `!(pat)`, `@(pat)`, `+(pat)`, `*(pat)` and `?(pat)` are off by default. What does each of them match, how do you turn them on, and why can the single line `shopt -s extglob; echo !(*.txt)` still fail with a syntax error?

level: middleimportance: should knowfreq 36%

basics

~20 s

With shopt -s extglob, bash adds five operators over a pipe-separated pattern list: ?(p) zero or one, *(p) zero or more, +(p) one or more, @(p) exactly one, and !(p) anything that does not match. Enabling it on the same line fails because bash parses the whole line before running any of it.

open as a page

A cleanup step runs `rm -rf "$dir"/*` but `.env` and `.cache` survive inside $dir, so a colleague adds `rm -rf "$dir"/.*`. Explain both behaviours in terms of bash pathname expansion, and give a safe fix.

level: seniorimportance: should knowfreq 44%

basics

~20 s

A leading dot must be matched explicitly, so * never expands to hidden names and they survive the cleanup. The proposed .* is worse: it also matches . and .., putting the parent directory on the command line. Use shopt -s dotglob, or remove and recreate the directory.

open as a page

What does the pattern `**` expand to in bash, what has to be enabled for it to be recursive, and how does `for f in **/*.js` differ from `find . -name '*.js'`?

level: middleimportance: nice to knowfreq 34%

basics

~20 s

Without shopt -s globstar, ** behaves like a single * and stays inside one directory level. With globstar on (bash 4.0+), **/ matches zero or more directories, so **/*.js recurses. Unlike find, it still skips hidden entries and expands entirely in memory.

open as a page