Skip to content
shellhacks.dev
Regular Expressions

How to Convert Glob Patterns to Regex Without Surprises

Convert glob patterns to regex: what star and question mark mean, the recursive form, escaping dots, conversion rules and library helpers.

6 min read
Summarize in AI
Illustration for "How to Convert Glob Patterns to Regex Without Surprises": a terminal session, a regular expression and its match, the Linux penguin.

Shell patterns and regular expressions look similar and mean different things. The confusion shows in questions like "why does grep *.txt not work?" and "why does a .gitignore pattern not do what find does?". Converting between them is mechanical once the rules are written down. This post gives the rules and a tested translation function, and complements escaping special characters. The examples assume Python 3.13 and Bash 5.2.

The difference in one table

Text
Meaning                 Glob          Regex (ERE)
any characters          *             .*
exactly one character   ?             .
one of a set            [abc]         [abc]
not one of a set        [!abc] [^abc] [^abc]
literal dot             .             \.
literal plus            +             \+
start / end of name     implicit      ^ and $

A glob is anchored by definition: *.txt matches names that end in .txt, in their entirety. A regex matches anywhere unless anchored. So *.txt as a regex would be an error or match something unexpected, and .*\.txt$ is the regex equivalent.

Convert by hand

Shell
glob='report-202?-*.csv'
regex=$(printf '%s' "$glob" | sed -e 's/[.+^$(){}|\\]/\\&/g' -e 's/\*/.*/g' -e 's/?/./g')
printf '^%s$\n' "$regex"
ls | grep -E "^${regex}$"
Text
^report-202.-.*\.csv$
report-2026-q3.csv

The order is important: escape the literal metacharacters first, then replace the glob wildcards, otherwise the dots you add for * would be escaped. The sed expression above leaves bracket expressions alone, which is fine for simple cases and needs more care for ranges. For shell use, a direct approach avoids conversion altogether: [[ $name == $glob ]] matches a glob in Bash.

Use a library

Python
import fnmatch
import re

print(fnmatch.translate("report-202?-*.csv"))
rx = re.compile(fnmatch.translate("*.log"))
print([n for n in ["a.log", "b.txt", "c.log.gz"] if rx.match(n)])
Text
(?s:report\-202.\-.*\.csv)\Z
['a.log']

Python's fnmatch.translate returns a regex string with the glob rules applied, escaping and anchoring. Note that its * becomes .*, which crosses directory separators, unlike a shell glob. For paths, pathlib.PurePath.match and glob.translate (added in Python 3.13) treat / specially. In JavaScript, packages such as picomatch and minimatch do the same; in Go, path.Match matches directly.

Slashes, hidden files and **

Shell
shopt -s globstar nullglob
ls src/**/*.py
echo *.txt
find . -name '*.txt'
find . -regex '.*\.txt'

In the shell, * does not match a leading dot (hidden files) or a slash. Enabling globstar makes ** recurse into subdirectories. find -name uses glob rules on the file name only, and find -regex matches the entire path with a regex, which is a common reason a pattern fails to match. .gitignore files use a glob dialect with its own rules, including ** and negation with !.

Surprises

An unquoted glob is expanded by the shell before the command sees it: find . -name *.txt works only while the current directory has no .txt file; always quote it. With no match, Bash passes the literal pattern unless nullglob is on. Extended globs (@(a|b), +(x)) need shopt -s extglob and have their own regex equivalents. Character classes can behave differently by locale, as in POSIX character classes.

Why the two notations exist

Globs were designed for a narrow job: naming sets of files on a command line, where people type patterns constantly. They are terse, with a few wildcard characters and no repetition or alternation beyond brace expansion, and they are anchored by definition because a file name either matches or does not. Regular expressions were designed to describe arbitrary text, so they have quantifiers, groups and alternation, and they are unanchored because a search finds a pattern inside a larger string. Each is shaped by its job, which is why neither can fully replace the other and why translations lose information.

Expressiveness runs one way. Every glob has an equivalent regex, but most regexes have no glob equivalent. A pattern like "two digits, a dash, then one or more letters" is easy as a regex and impossible as a plain glob. Extended globs in Bash narrow the gap with constructs such as +([0-9]), which is effectively a regex piece in different punctuation, and are useful but not portable beyond Bash and Korn-style shells.

Translating bracket expressions and ranges

Brackets look identical in both and differ in the details. A glob negates with ! or ^, depending on the shell and tool, while a regex uses only ^. Inside a regex bracket, backslash is a literal in POSIX syntax and an escape in Perl-style syntax, so [\d] means a backslash or the letter d in one and a digit in the other. A hyphen in the middle of a bracket forms a range, and its meaning in non-ASCII locales depends on collation order, which is why ranges such as [a-z] can include uppercase letters in some old locale settings. Use named classes such as [[:lower:]] when portability matters, as covered elsewhere on this site.

When writing a converter, handle brackets as units. Copy the bracket expression through unchanged except for the negation character, rather than escaping its contents character by character. A converter that treats [abc] as three literals and a pair of brackets produces a regex that matches the wrong thing, and the bug appears only for patterns that use sets.

Testing a conversion

A translation function deserves a table of cases, the same as any parser. Include patterns with a literal dot, a question mark in the middle, a star at both ends, a bracket set, a negated set, a name with a plus sign or a space and the empty pattern. For each, list names that must match and names that must not, including near misses like report.csv.bak for the pattern *.csv. Run the same table through the shell's own matching and through the converted regex and compare; any difference is a conversion bug, and the shell is the authority.

Watch the end of the string. A regex produced by a translation must be anchored at both ends, or *.log matches a.log.gz. Python's fnmatch.translate ends with \Z for that reason, and a hand-written converter that forgets the anchors passes simple tests and fails on the first backup file in the directory.

Choosing the right tool for a filter

When filtering files, decide where the matching happens. If the filtering is done by find or rsync, give them a glob and let them apply it. If the data comes from a list in a file or from a log, regular expressions fit better. A pipeline that lists files with ls and filters with grep works until a name contains a newline; find -print0 with xargs -0 avoids parsing names at all. Match files with file-oriented tools and text with text-oriented ones, and the conversion question seldom arises.

Where this does not apply

Do not convert when the target tool accepts globs natively, as with rsync --include, ls, tar --wildcards and most file APIs. A conversion also loses the shell's extras like brace expansion, which happens before globbing ({a,b} becomes two patterns). See the Bash manual on pattern matching and the fnmatch documentation.

A related point for scripts: when you only want to filter a list of names with a glob, case "$name" in *.log) ... ;; esac in POSIX shell does it with no regex and no subprocess, as in case patterns in sh. Reach for the conversion only when the target API demands a regex, and test with sample cases.

Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.