Desk Clarity

A field guide to everyday digital problems.

Developer tools / Practical guide

Python Path.match Misses Recursive Paths: Use full_match for a Rooted Pattern

Filter root-relative paths with Python full_match, test recursive depth and unwanted prefixes, and specify path flavor and case policy.

By ON Lab4 min readSources checked November 10, 2026 (KST)

A pattern such as src/**/*.py looks like a recursive filter, but PurePath.match() does not give it the same meaning as filesystem globbing. It can reject a file directly under src, reject a deeply nested file, and accept a path whose beginning you meant to exclude. For an already collected list of root-relative path strings, Python 3.13 and later provide full_match() for recursive whole-path matching. First define the path root and path syntax; then choose the matching operation.

Reproduce the depth and prefix surprises

The following example performs no filesystem access. It can run even if none of these files exist. Using PurePosixPath makes the slash and case rules explicit instead of inheriting the machine's path flavor.

from pathlib import PurePosixPath

names = [
    "src/top.py",
    "src/a/one.py",
    "src/a/b/deep.py",
    "vendor/src/a/extra.py",
]
actual = [PurePosixPath(n).match("src/**/*.py") for n in names]
assert actual == [False, True, False, True]
print(actual)

The results follow the documented match() contract. For a relative pattern it matches from the right side of the path. Its ** has the nonrecursive behavior of * within this operation, so the middle component consumes exactly one directory here. The extra vendor prefix therefore does not prevent a match. This is a mismatch between the chosen API and the filtering requirement, rather than proof of a current Python defect.

A historical reader report used a/b/c/d/e with a/**/* and a/*/**/*. Those produce different results with match() because the number of pattern components matters. Adding more stars until one example passes does not establish a recursive rule for arbitrary depths.

Match the entire root-relative path on Python 3.13 and later

Use full_match() when the input is an existing logical list and the requirement is that the complete relative path conforms to the pattern. Run this example with Python 3.13 or later.

from pathlib import PurePosixPath

names = [
    "src/top.py",
    "src/a/one.py",
    "src/a/b/deep.py",
    "vendor/src/a/extra.py",
]
actual = [PurePosixPath(n).full_match("src/**/*.py") for n in names]
assert actual == [True, True, True, False]
selected = [n for n, keep in zip(names, actual) if keep]
assert selected == names[:3]
print(selected)

Here **/ can cover zero or more directory levels. A direct child, one nested directory, and two nested directories all pass. Whole-path matching also rejects the unwanted prefix. All four cases matter: testing only the middle row would make the old and new expressions look equivalent.

“Root-relative” describes the input contract. It does not mean adding a leading slash to every pattern. In this example the application has chosen a project root and supplied paths relative to it. Passing /home/user/project/src/top.py to this relative pattern changes the question. Normalize the source of the names deliberately before filtering, and keep absolute and relative inputs separate.

Specify Windows syntax and case policy deliberately

Pure path objects can represent a foreign platform without accessing it. Their default case policies differ. These assertions show both defaults and an explicit override on Python 3.13 and later.

from pathlib import PurePosixPath, PureWindowsPath

assert not PurePosixPath("src/MAIN.PY").full_match("src/*.py")
assert PurePosixPath("src/MAIN.PY").full_match(
    "src/*.py", case_sensitive=False
)
assert PureWindowsPath("src/MAIN.PY").full_match("src/*.py")
assert not PureWindowsPath("src/MAIN.PY").full_match(
    "src/*.py", case_sensitive=True
)
print("Explicit path flavor and case checks passed")

Choose the flavor that matches the data producer, such as a manifest using POSIX separators, rather than guessing from the host running the filter. An explicit case policy can be useful when the same manifest runs on multiple systems. It still does not establish whether a particular filesystem treats two names as the same file.

Handle older Python without promising an equivalent shortcut

Python 3.11 does not expose full_match(). If whole-path recursive matching is an application requirement, declare Python 3.13 or later and check the runtime used by the job, editor, and deployment. A newer shell interpreter does not upgrade a virtual environment automatically.

Do not silently replace this expression with fnmatch.fnmatch(), replace ** with *, or add a guessed prefix. Those choices have different separator, recursion, or case behavior. If upgrading is unavailable, select and test an implementation against the four depth/prefix cases above and the project's case policy. This guide does not claim an untested compatibility replacement.

If the task is to discover files on disk, use the traversal API appropriate to that task. The related pathlib hidden-file guide covers discovery and component filtering. A logical list filter should not walk the disk just to decide whether a string matches.

Keep matching separate from security and existence checks

Neither method proves that a file exists, resolves symbolic links, nor establishes a safe directory boundary. A path containing .. can still be a logical path string; matching is not a sandbox check. Reject or resolve such input according to the application's separate security policy before using it to open files. For a manifest filter, document the chosen root, separator convention, supported Python version, and whether case matters. Then retain the direct-child, deep-child, and unwanted-prefix checks as regression examples.

Sources & further reading