Python 3.12 added pathlib.Path.walk(), bringing directory-tree traversal directly to Path objects. It fills the same broad role as os.walk(), but keeping traversal and path manipulation in pathlib can make filesystem code easier to read.

Walking a tree is deceptively simple, though. Production code needs to decide which subtrees to enter, what to do with unreadable directories, whether symbolic links should be followed, and whether the filesystem may change during traversal.

This article builds those decisions explicitly.

Start with the traversal contract

Path.walk() yields a three-item tuple for every visited directory:

from pathlib import Path

root = Path("workspace")

for directory, dirnames, filenames in root.walk():
    print("directory:", directory)
    print("subdirectories:", dirnames)
    print("files:", filenames)

directory is a Path. The entries in dirnames and filenames are strings, not complete Path objects. Join them to the current directory when you need the full path:

for directory, dirnames, filenames in root.walk():
    for name in filenames:
        path = directory / name
        print(path)

Do not assume either list is sorted. Directory enumeration order depends on the filesystem. If stable ordering matters for tests, reports, or reproducible output, sort explicitly:

for directory, dirnames, filenames in root.walk():
    dirnames.sort()
    for name in sorted(filenames):
        print(directory / name)

Sorting dirnames during a top-down walk has an additional effect: it controls the order in which subdirectories are visited.

Prune directories in place

By default, Path.walk() traverses top-down. In this mode, the caller may modify dirnames in place before traversal continues. Names removed from that list are not visited.

That makes pruning cheap and direct:

from pathlib import Path

SKIP = {".git", ".venv", "node_modules", "__pycache__"}

for directory, dirnames, filenames in Path(".").walk():
    dirnames[:] = [name for name in dirnames if name not in SKIP]

    for name in filenames:
        print(directory / name)

The slice assignment is intentional. Rebinding the local variable does not modify the list that controls traversal:

# Wrong for pruning:
dirnames = [name for name in dirnames if name not in SKIP]

Instead, mutate the existing list with slice assignment, del, remove(), or another in-place operation.

Pruning is useful for more than performance. It can encode a traversal policy: do not inspect dependency directories, generated output, mount-like subtrees, or directories the application is not supposed to process.

When top_down=False, changing dirnames does not affect traversal because the children have already been visited by the time the parent tuple is yielded.

Choose top-down or bottom-up deliberately

Top-down traversal is a natural fit for discovery because it allows pruning before recursion:

for directory, dirnames, filenames in root.walk(top_down=True):
    dirnames[:] = [name for name in dirnames if not name.startswith(".")]
    inspect(directory, filenames)

Bottom-up traversal is useful when an operation must process children before their parent. Removing a directory tree is the classic example because a directory normally must be empty before it can be removed:

from pathlib import Path

root = Path("scratch")

for directory, dirnames, filenames in root.walk(top_down=False):
    for name in filenames:
        (directory / name).unlink()

    for name in dirnames:
        (directory / name).rmdir()

root.rmdir()

Deletion code deserves extra safeguards around the chosen root, permissions, symlinks, and concurrent changes. The important traversal rule is that bottom-up ordering gives child entries a chance to disappear before their parent directory is removed.

Make error handling a policy

Filesystem traversal can encounter directories that disappear, permission failures, broken mounts, and other OSError conditions.

By default, errors raised while scanning directories are ignored by Path.walk(). For a best-effort indexing job, that may be acceptable. For backups, integrity checks, deployment packaging, or security-sensitive scans, silently missing a subtree may be unacceptable.

Use on_error to make the choice explicit:

from pathlib import Path


def fail_on_error(error: OSError) -> None:
    raise error


for directory, dirnames, filenames in Path("data").walk(
    on_error=fail_on_error,
):
    process(directory, filenames)

An OSError passed to the callback exposes its related filename through the exception’s filename attribute when available. A best-effort crawler can record the problem and continue:

import logging
from pathlib import Path

log = logging.getLogger(__name__)


def report_error(error: OSError) -> None:
    log.warning("cannot scan %r: %s", error.filename, error)


for directory, dirnames, filenames in Path("data").walk(
    on_error=report_error,
):
    process(directory, filenames)

Do not choose between ignoring and failing merely by convenience. Decide whether an incomplete traversal is still a valid result for the operation.

Path.walk() does not follow symbolic links by default.

One detail is especially important when migrating from os.walk(): with follow_symlinks=False, a symbolic link that points to a directory is listed in filenames, not dirnames.

That means this code may receive directory symlinks in the file loop:

for directory, dirnames, filenames in root.walk():
    for name in filenames:
        path = directory / name
        # path is not guaranteed to be a regular file.
        handle(path)

If the operation specifically requires regular files, check that property rather than treating membership in filenames as proof.

This difference matters when porting traversal code from os.walk(), whose categorization of symlinks differs.

You can request symlink traversal with follow_symlinks=True:

for directory, dirnames, filenames in root.walk(follow_symlinks=True):
    ...

Do so only when following links is actually part of the operation’s semantics.

Path.walk() does not track directories it has already visited. A symbolic link can therefore create a cycle, such as a child directory linking back to one of its ancestors. With symlink following enabled, that can cause unbounded recursion through the same logical tree.

A crawler that must follow links needs its own cycle and boundary policy. Depending on the application, that may mean tracking filesystem identities, restricting traversal to a trusted tree, imposing depth or work limits, or avoiding link traversal entirely.

Checking only textual path prefixes is not a complete filesystem boundary: symbolic links and concurrent filesystem changes can make textual paths differ from the objects ultimately accessed.

A walk is not a filesystem snapshot

Directory trees can change while they are being traversed.

Path.walk() assumes the directories it walks are not modified during execution. A name observed as a directory can be renamed, removed, or replaced before the next filesystem operation. In particular, the documentation warns that a directory in dirnames can be replaced by a symlink before descent.

This creates a general rule for filesystem code: information learned during enumeration is not a permanent guarantee about a later operation.

For example:

for directory, dirnames, filenames in root.walk():
    for name in filenames:
        path = directory / name
        if path.is_file():
            consume(path)

Between is_file() and consume(), another process can replace path. The check may still be useful for ordinary best-effort tools, but it should not be treated as a security boundary or transaction.

If correctness requires a stable snapshot, use storage or operating-system mechanisms that actually provide the required consistency rather than expecting a directory walker to manufacture it.

Separate discovery from acceptance policy

A useful pattern is to let Path.walk() discover candidates while a separate function decides which paths the application accepts.

from pathlib import Path

ALLOWED_SUFFIXES = {".json", ".toml"}
SKIP_DIRS = {".git", ".cache"}


def iter_config_files(root: Path):
    for directory, dirnames, filenames in root.walk(on_error=_raise):
        dirnames[:] = [name for name in dirnames if name not in SKIP_DIRS]

        for name in filenames:
            path = directory / name
            if path.suffix in ALLOWED_SUFFIXES and path.is_file():
                yield path


def _raise(error: OSError) -> None:
    raise error

The traversal mechanism now has a small job: enumerate the tree and prune known subdirectories. The acceptance policy handles file types and other application rules.

For untrusted trees, add resource limits too. A tiny directory tree is cheap to walk; a tree containing millions of entries is not. Depending on the service, sensible limits may include maximum files examined, maximum depth, total bytes processed, or an overall deadline enforced outside the iterator.

Do not confuse walk with glob

Path.rglob() is convenient when the task is fundamentally recursive pattern matching:

for path in root.rglob("*.py"):
    print(path)

Path.walk() is a better fit when the traversal itself needs policy: pruning directories, choosing top-down versus bottom-up ordering, handling scan errors explicitly, or treating directory and file names differently.

Neither API is universally better. Choose the abstraction that expresses the operation you actually need.

Test trees with awkward structure

Filesystem traversal tests should go beyond a flat temporary directory.

Useful cases include:

  • nested directories that should be visited;
  • a directory pruned by mutating dirnames;
  • deterministic ordering when the application promises it;
  • an empty directory;
  • a symbolic link to a file;
  • a symbolic link to a directory when the platform supports it;
  • a broken symbolic link;
  • scan errors where permissions and the test platform allow reliable construction;
  • a bottom-up operation where children must be processed first.

When symlink following is enabled, test the cycle policy independently rather than constructing a test that can recurse forever if the protection breaks.

Also keep platform differences in mind. Symlink creation may require different permissions or capabilities on different operating systems, so tests should distinguish unsupported setup from a traversal failure.

Keep traversal semantics visible

Path.walk() makes recursive filesystem code fit naturally with pathlib, but its most useful features are the controls around recursion.

Use top-down traversal when you need to prune dirnames; use bottom-up traversal when children must be processed before parents. Decide whether scan errors are acceptable instead of inheriting the default silently. Remember that directory symlinks appear in filenames when links are not followed, and treat follow_symlinks=True as a deliberate change that requires a cycle policy.

Finally, remember what a directory walk cannot promise. It does not freeze the filesystem, make later path operations race-free, or turn enumeration into a security boundary. Keep discovery, acceptance rules, resource limits, and filesystem consistency requirements separate, and Path.walk() becomes a clear building block rather than an accidental policy engine.