os.walk() in Python: practical traversal patterns that scale

Author note: I skipped the content skill because its mandatory data statements and recommendation tables conflict with the requested blog-only format.

I once inherited a codebase with a chaotic assets folder: build artifacts, legacy scripts, random PDFs, and a few hidden directories that ballooned the repo size. The quickest way to get answers was to walk the file tree and inventory everything. That is where os.walk() becomes your everyday tool. It gives you a disciplined, repeatable way to traverse a directory structure, whether you are cleaning up a monorepo, checking for stale backups, or running a static analysis pass. You should think of it like walking a neighborhood with a clipboard: you choose the route, decide which streets to skip, and record what you see.

You are going to learn how os.walk() actually emits data, how to control the traversal, how to build real utilities that you can reuse, and how to avoid common mistakes that cause subtle bugs. I will also show you when you should not reach for os.walk() and what to use instead in 2026 workflows. If you already know the basics, you will still get a few practical patterns you can drop into your next tool or script.

A simple mental model that never fails me

I treat os.walk() as a generator that gives me a stream of directories. Each yielded item has three pieces: the current directory path, a list of subdirectory names, and a list of file names. If you know that, you can build any traversal strategy you want.

Here is the key shape:

  • root is the current directory path.
  • dirs is a list of subdirectory names under root.
  • files is a list of file names under root.

The subtlety is that dirs and files are names, not full paths. That is by design, so you can join paths efficiently and decide how to change the traversal as you go. When topdown=True (the default), os.walk() yields a directory before walking into its children. That means you can edit the dirs list in place to prune branches. When topdown=False, it yields children first and then the parent, which is great for cleanup work.

Think of it as a mail carrier route. Top-down is when you start at the main road and choose which streets to enter. Bottom-up is when you collect everything in the side streets and then return to the main road to reconcile what you saw.

The baseline traversal you can run right now

Here is the simplest complete example I use when I want to see the shape of a tree. It is intentionally verbose so you can see every piece of the tuple:

import os

if name == "main":

for root, dirs, files in os.walk(".", topdown=True):

print(root)

print(dirs)

print(files)

print("-" * 40)

When you run it, you get a snapshot for each directory. Notice the order: the root directory is printed first, then its child directories, then deeper levels. That is top-down. You can take this same loop and pipe the output into logs, JSON lines, or a CSV report.

If you want to include full paths, you can join them on the fly:

import os

if name == "main":

for root, dirs, files in os.walk("."):

for name in files:

full_path = os.path.join(root, name)

print(full_path)

This pattern is the backbone of many command-line tools. It is also the fastest way to answer questions like “Where are all my .env files?” or “How many .md files are under this repo?”

Shaping the walk: prune, sort, and skip junk

The biggest superpower of os.walk() is that you can mutate dirs when topdown=True. I do this constantly to avoid scanning node_modules, .git, or build outputs. You should treat this as mandatory when you are walking large trees.

Here is a real-world prune pattern:

import os

SKIPDIRS = {".git", "nodemodules", "dist", "build", "pycache"}

if name == "main":

for root, dirs, files in os.walk(".", topdown=True):

# Remove skipped directories in-place so os.walk() never visits them

dirs[:] = [d for d in dirs if d not in SKIP_DIRS]

# Optional: sort for stable output

dirs.sort()

files.sort()

for name in files:

if name.endswith(".py"):

print(os.path.join(root, name))

Notice the dirs[:] = ... pattern. That is a direct mutation of the list object that os.walk() is using internally. If you reassign dirs instead of slicing, your filter will not take effect. I see this mistake all the time.

You can also control whether you follow symbolic links by passing followlinks=True. I use it sparingly. It is easy to create cycles in a tree when symlinks point back to a parent directory, and that can turn a simple scan into an infinite loop.

Nested list comprehension for fast filtering

Sometimes you want a concise list of files without a lot of logic. A nested list comprehension works well, and it still runs through the same os.walk() generator:

import os

if name == "main":

python_files = [

file

for , , files in os.walk(".")

for file in files

if file.endswith(".py")

]

print("python files in the directory tree are")

for name in python_files:

print(name)

This pattern is clean for small scripts, but I do not use it for large trees because it loads everything into memory at once. If you are scanning a big codebase, you should yield results lazily and stream them to the output instead of building a huge list.

Real-world patterns I use in production scripts

Below are three patterns that cover most practical use cases: reporting, cleanup, and content analysis. Each example is complete and runnable.

1) Build a size report for large files

When a repo grows too fast, I need the top offenders. I walk the tree, skip junk, and track files over a size threshold:

import os

from dataclasses import dataclass

from typing import List

@dataclass

class LargeFile:

path: str

size_bytes: int

SKIPDIRS = {".git", "nodemodules", "dist", "build"}

def findlargefiles(root: str, minsizebytes: int) -> List[LargeFile]:

results: List[LargeFile] = []

for dirpath, dirnames, filenames in os.walk(root, topdown=True):

dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]

for name in filenames:

full_path = os.path.join(dirpath, name)

try:

size = os.path.getsize(full_path)

except OSError:

# File may disappear between listing and size check

continue

if size >= minsizebytes:

results.append(LargeFile(full_path, size))

return results

if name == "main":

big = findlargefiles(".", minsizebytes=50 1024 1024)

for item in sorted(big, key=lambda x: x.size_bytes, reverse=True):

print(f"{item.size_bytes:>12} {item.path}")

This is how I quickly justify a cleanup or move binary assets to object storage. It is also a neat way to verify that a .gitignore rule is working.

2) Safe cleanup with bottom-up traversal

If you need to delete empty directories, bottom-up is the right tool. It ensures you process children before parents, so you can remove directories that become empty after cleaning up files.

import os

if name == "main":

for root, dirs, files in os.walk(".", topdown=False):

# Skip if there are still files

if files:

continue

# If no subdirs and no files, remove the directory

if not dirs:

try:

os.rmdir(root)

print(f"removed: {root}")

except OSError:

pass

This is the same idea as cleaning a closet from the bottom shelf to the top. You do not want to remove the top shelf before you know what is underneath.

3) Content audit: find files that reference a string

I often need to find configs referencing a domain or feature flag. This scan keeps errors local and reads only text-like files:

import os

TEXT_EXTENSIONS = {".py", ".txt", ".md", ".yml", ".yaml", ".json"}

def find_references(root: str, needle: str) -> None:

for dirpath, dirnames, filenames in os.walk(root):

for name in filenames:

_, ext = os.path.splitext(name)

if ext not in TEXT_EXTENSIONS:

continue

full_path = os.path.join(dirpath, name)

try:

with open(full_path, "r", encoding="utf-8", errors="ignore") as f:

for i, line in enumerate(f, start=1):

if needle in line:

print(f"{full_path}:{i}: {line.strip()}")

break

except OSError:

continue

if name == "main":

findreferences(".", "FEATUREX")

If you are running this inside a modern editor, you can wire it into a task runner and get results as clickable paths.

Mistakes and edge cases I see most often

os.walk() looks simple, but a few pitfalls can waste hours. These are the ones I actively guard against.

1) Assuming dirs and files are full paths. They are just names. You must join them with root.

2) Reassigning dirs instead of mutating it. The prune logic only works if you modify dirs in place. The correct pattern is dirs[:] = ....

3) Ignoring permission errors. A directory can be readable when you list it but not accessible when you open a file. Always expect OSError during reads or stats.

4) Following symlinks without a plan. When you set followlinks=True, you can loop forever if there is a link back to an ancestor. If you must follow symlinks, track visited inodes or use a depth limit.

5) Assuming files stay put. In active directories, files can appear or disappear between listing and opening. That is normal. Catch exceptions and keep going.

Here is a defensive wrapper I use when I need stability:

import os

from typing import Iterator, Tuple, List

def safe_walk(root: str) -> Iterator[Tuple[str, List[str], List[str]]]:

for dirpath, dirnames, filenames in os.walk(root, topdown=True):

try:

# Force evaluation to catch errors early

_ = list(dirnames)

_ = list(filenames)

yield dirpath, dirnames, filenames

except OSError:

# Skip unreadable directories

continue

This does not solve all race conditions, but it gives you a predictable place to add logging or metrics.

Performance and scaling: what to expect in 2026

File traversal is I/O-bound. The fastest code still waits on the filesystem. On a typical SSD, scanning a small directory often takes around a few milliseconds, and a larger directory with thousands of entries can take a few tens of milliseconds just to list metadata. Network filesystems are slower and more variable, and cloud-mounted drives can swing widely based on cache state.

os.walk() uses os.scandir() under the hood on modern Python, which is much faster than old list-based directory calls. The biggest performance wins come from reducing work, not from micro-tuning Python loops.

Here is how I keep scans quick and predictable:

  • Prune aggressively. Skip large vendor directories early.
  • Avoid stat unless you need it. os.path.getsize() or os.stat() adds extra I/O. If you only need file names, do not ask for sizes.
  • Limit depth when you can. If you only need the top two levels, track depth and stop descent.
  • Stream results. Write to a file or yield results instead of building huge lists.
  • Batch work for AI tooling. If you are feeding files into a code assistant or summarizer, batch them in chunks of 20–50 files to keep memory steady.

I avoid multi-threading for local disk scans unless I am doing expensive content analysis. Parallel reads can saturate the disk and actually slow everything down. A good compromise is to walk sequentially, but offload CPU-heavy processing of each file to a thread or process pool.

When I use os.walk() — and when I don’t

os.walk() is ideal when you need a full tree traversal, when you need to prune directories, and when you want a stable, testable algorithm. I do not use it when I only need a single glob match or when I need near real-time updates.

Here is a practical comparison of traditional and modern approaches that I use in 2026 workflows:

Task

Traditional approach

Modern approach

My pick

Walk full repo with pruning

os.walk() with dirs[:] filtering

pathlib.Path.rglob() with manual pruning

os.walk() for control

Find files by pattern

glob.glob("/.py", recursive=True)

pathlib.Path.rglob(".py")

Path.rglob() for clarity

React to file changes

Periodic os.walk() scan

File watcher (inotify/FSEvents/Watchman)

File watcher for speed

Build a file index

os.walk() + custom CSV

os.walk() + SQLite or lightweight index

os.walk() plus index

Scan network shares

os.walk() with long timeouts

Remote index service or API

Avoid os.walk() if possibleIf you are writing a long-running service that watches files, use a watcher. If you are writing a one-off report or a cleanup script, os.walk() is still the best fit.

A practical depth-limited traversal pattern

Depth limits are a common need. You might only want to scan src/ two levels deep or skip nested vendor packages. I track depth by counting separators relative to the root.

import os

def walklimited(root: str, maxdepth: int):

root = os.path.abspath(root)

for dirpath, dirnames, filenames in os.walk(root, topdown=True):

depth = dirpath.count(os.sep) - root.count(os.sep)

if depth >= max_depth:

# Prevent descending further

dirnames[:] = []

yield dirpath, dirnames, filenames

if name == "main":

for root, dirs, files in walklimited(".", maxdepth=2):

print(root)

This technique is simple and reliable. It is also easy to test because it does not depend on file ordering or hidden state.

Making it friendly for teams and automation

When I ship utilities that use os.walk(), I make them predictable for teammates and CI pipelines. That means deterministic order, clear logging, and good failure behavior.

I add these small touches:

  • Sort dirs and files so output does not change across runs.
  • Print relative paths so logs are readable.
  • Add --root and --exclude flags for CLI tools.
  • Exit with non-zero status if a required path is missing.

Here is a tiny command-line tool pattern that I reuse:

import argparse

import os

import sys

def main() -> int:

parser = argparse.ArgumentParser()

parser.add_argument("root", nargs="?", default=".")

parser.add_argument("--ext", default=".py")

parser.add_argument("--exclude", action="append", default=[])

args = parser.parse_args()

root = os.path.abspath(args.root)

if not os.path.isdir(root):

print(f"missing directory: {root}")

return 2

skip = set(args.exclude)

for dirpath, dirnames, filenames in os.walk(root, topdown=True):

if skip:

dirnames[:] = [d for d in dirnames if d not in skip]

dirnames.sort()

filenames.sort()

for name in filenames:

if name.endswith(args.ext):

rel = os.path.relpath(os.path.join(dirpath, name), root)

print(rel)

return 0

if name == "main":

raise SystemExit(main())

That is a small amount of extra work for a much smoother team experience.

Traversal order, determinism, and reproducibility

When you build tooling that people rely on, nondeterministic order creates confusion. Even if the output is correct, shuffled results make diffs noisy and caching less effective. os.walk() does not guarantee sorting, and the filesystem can return entries in different orders across runs.

If you need deterministic output, sort dirnames and filenames every time. You can also impose your own ordering scheme, for example, prioritizing src/ before tests/, or ignoring files with a certain prefix. I often do this when creating reports that are reviewed in code reviews.

If you are building a cache, include the traversal order in your hash logic or normalize output by sorting. That way, a change in directory order does not invalidate the cache.

Filtering by patterns without extra dependencies

Sometimes you want more expressive filters than a simple suffix check. You can use fnmatch or a compiled regex without leaving the standard library.

import fnmatch

import os

PATTERNS = [".py", ".md", "*.toml"]

def matches_any(name: str) -> bool:

return any(fnmatch.fnmatch(name, pat) for pat in PATTERNS)

def walkwithpatterns(root: str):

for dirpath, dirnames, filenames in os.walk(root):

for name in filenames:

if matches_any(name):

yield os.path.join(dirpath, name)

if name == "main":

for path in walkwithpatterns("."):

print(path)

If you need even more precision, a compiled regex is fine, but I avoid complex regex unless I am matching structured naming conventions like 2025-01-18_report.json.

Symlink safety: following links without looping

Symlinks are powerful, but they can create cycles. If you must set followlinks=True, track visited directories by inode and device. This lets you avoid walking the same directory twice even if it is reachable via multiple paths.

import os

from typing import Iterator, Tuple, List, Set

def walkfollowlinks_safe(root: str) -> Iterator[Tuple[str, List[str], List[str]]]:

seen: Set[tuple] = set()

for dirpath, dirnames, filenames in os.walk(root, topdown=True, followlinks=True):

try:

st = os.stat(dirpath)

except OSError:

continue

key = (st.stdev, st.stino)

if key in seen:

# Prevent cycles or repeated visits

dirnames[:] = []

continue

seen.add(key)

yield dirpath, dirnames, filenames

This is conservative, but it prevents runaway scans in monorepos that use symlinked packages or shared build caches.

Windows and cross-platform gotchas

os.walk() works the same across platforms, but file naming rules and permission behaviors vary. A few things I keep in mind:

  • On Windows, long paths can still be an issue in some environments. If your traversal fails in deep directory trees, normalize with os.path.abspath and keep paths short when possible.
  • Path separators differ, so use os.path.join and os.path.relpath instead of manual string concatenation.
  • Case sensitivity varies. If you are filtering by extension, normalize with name.lower().endswith(".py") so you do not miss .PY on case-insensitive systems.

These sound basic, but they prevent the subtle “works on my machine” issues that show up in CI.

A hybrid approach: os.walk for discovery, pathlib for actions

I like os.walk() for traversal control, but pathlib for file operations and readability. You can combine them easily by converting paths as you go.

import os

from pathlib import Path

def walkwithpathlib(root: str):

for dirpath, dirnames, filenames in os.walk(root):

base = Path(dirpath)

for name in filenames:

yield base / name

if name == "main":

for path in walkwithpathlib("."):

if path.suffix == ".md":

print(path.read_text(encoding="utf-8", errors="ignore")[:60])

This gives you the best of both worlds: traversal control and modern file APIs.

Depth-first vs breadth-first: choosing the right strategy

os.walk() is depth-first by default. It goes deep into a branch before moving to siblings. This is usually fine, but sometimes you want to prioritize higher-level directories for faster insight. In that case, you can use a manual queue with os.scandir() for a breadth-first traversal. I only do this for large tree audits where I want early answers from the top levels.

If you are happy with depth-first but want to prioritize certain directories, you can sort dirnames so your preferred branches are visited first. That is a simple trick that produces real-world speedups when you only need partial results.

Building an index you can query later

If you frequently scan the same repo, build an index in a lightweight database. That lets you ask questions without re-walking every time. You can keep it simple: a SQLite table with path, size, mtime, and file type.

import os

import sqlite3

from pathlib import Path

def indextree(root: str, dbpath: str) -> None:

conn = sqlite3.connect(db_path)

conn.execute(

"create table if not exists files (path text primary key, size integer, mtime real)"

)

for dirpath, dirnames, filenames in os.walk(root):

for name in filenames:

full_path = os.path.join(dirpath, name)

try:

st = os.stat(full_path)

except OSError:

continue

conn.execute(

"insert or replace into files(path, size, mtime) values (?, ?, ?)",

(fullpath, st.stsize, st.st_mtime),

)

conn.commit()

conn.close()

if name == "main":

indextree(".", "fileindex.db")

This makes “how many files changed since yesterday?” a query instead of a full scan.

Safer deletion and cleanup scripts

When you are deleting files, a small mistake can hurt. I add guardrails:

  • Require a --dry-run flag so I can see what would happen.
  • Validate the root path before deleting anything.
  • Only delete files that match a clear rule.

Here is a safe cleanup skeleton:

import argparse

import os

def cleanup(root: str, dry_run: bool) -> None:

for dirpath, dirnames, filenames in os.walk(root, topdown=True):

for name in filenames:

if name.endswith(".tmp"):

full_path = os.path.join(dirpath, name)

if dry_run:

print(f"would delete: {full_path}")

else:

try:

os.remove(full_path)

print(f"deleted: {full_path}")

except OSError:

print(f"failed: {full_path}")

def main() -> int:

parser = argparse.ArgumentParser()

parser.add_argument("root", nargs="?", default=".")

parser.addargument("--dry-run", action="storetrue")

args = parser.parse_args()

root = os.path.abspath(args.root)

if not os.path.isdir(root):

print(f"missing directory: {root}")

return 2

cleanup(root, args.dry_run)

return 0

if name == "main":

raise SystemExit(main())

The key is to make unsafe actions loud and deliberate.

Testing os.walk utilities without touching your real filesystem

I test traversal logic with temporary directories so I can control the layout and keep tests fast. The tempfile module and a few helper functions go a long way.

import os

import tempfile

def create_tree(root: str) -> None:

os.makedirs(os.path.join(root, "a", "b"), exist_ok=True)

with open(os.path.join(root, "a", "x.txt"), "w") as f:

f.write("x")

with open(os.path.join(root, "a", "b", "y.txt"), "w") as f:

f.write("y")

def testwalklimited():

with tempfile.TemporaryDirectory() as tmp:

create_tree(tmp)

roots = [r for r, , in walklimited(tmp, maxdepth=1)]

assert any(r.endswith(os.path.join(tmp, "a")) for r in roots)

You can also snapshot the result into a list and compare it to expected entries. Keep tests deterministic by sorting dirnames and filenames inside your helpers.

A small table of patterns I reuse

Goal

Pattern

Why it works —

— Skip heavy folders

dirs[:] = [d for d in dirs if d not in SKIP]

Prevents descent early Deterministic output

dirs.sort(); files.sort()

Stable logs and diffs Depth limiting

if depth >= max_depth: dirs[:] = []

Stops traversal cleanly Fast filtering

name.endswith(".py")

Cheap and readable Safe reads

open(..., errors="ignore")

Avoids Unicode crashes

This is the small toolkit that makes os.walk() feel boring in the best way possible.

How I use os.walk() with AI-assisted workflows

AI tooling is great at summarizing content, but it is only as good as the file set you feed it. os.walk() gives me a precise way to prepare inputs.

Here is my process:

1) Walk the repo and filter by file type.

2) Exclude generated content and vendor directories.

3) Batch the paths so each batch is a reasonable size.

4) Feed batches into the assistant with context about the tree structure.

A simple batching helper looks like this:

from typing import Iterable, List

def batch(items: Iterable[str], size: int) -> List[List[str]]:

out: List[List[str]] = []

current: List[str] = []

for item in items:

current.append(item)

if len(current) >= size:

out.append(current)

current = []

if current:

out.append(current)

return out

The key insight is that you should use os.walk() to define a “clean” file set. That is more valuable than any fancy summarizer prompt.

A simple decision checklist

When I reach for os.walk(), I ask these quick questions:

  • Do I need to traverse the entire tree, or can I glob a small subset?
  • Can I prune large directories to reduce I/O?
  • Is deterministic output required for CI or diffs?
  • Do I need content reads, or just names and paths?
  • Are there symlinks or permissions that could surprise me?

If I can answer those in a minute, the script is usually clean and reliable.

A short FAQ from my own use

Q: Is os.walk() still relevant with pathlib and rglob()?

Yes. pathlib is clean for pattern matching, but os.walk() gives you fine-grained control over traversal. I often use both.

Q: Can I speed it up with threads?

Only if you are doing heavy processing per file. For simple traversal, threads usually just compete for disk I/O.

Q: How do I handle huge repos?

Prune aggressively, limit depth, avoid stats, and stream results. If you need repeated queries, build an index.

Q: Why does my prune not work?

You likely reassigned dirs instead of modifying it in place. Use dirs[:] = ....

Final thoughts

os.walk() is not glamorous, but it is one of those tools that becomes indispensable once you rely on it. It scales from one-off scripts to production utilities, and it teaches you to think clearly about file structure and traversal behavior. When you pair it with disciplined pruning, stable ordering, and safe error handling, you get a file-walking toolset that will serve you for years.

If you only take one thing from this guide, let it be this: treat os.walk() as a controlled stream, not just a loop. The moment you do, it becomes a precise instrument instead of a blunt tool.

Scroll to Top