Modern Python Best Practices introduced pathlib as the modern alternative to os.path. This lesson goes deeper ā the full method surface that lets pathlib replace os.walk, shutil operations, and manual string parsing entirely, not just path joining.
1glob and rglob: Finding Files Without os.walk()
Before pathlib, finding every .py file in a directory tree meant either glob.glob("src/**/*.py", recursive=True) from the separate glob module, or manually walking the tree with os.walk() and filtering filenames yourself ā both work, but require importing a second module or writing manual filtering logic. Path.glob(pattern) and Path.rglob(pattern) (recursive glob, shorthand for glob("**/" + pattern)) fold that capability directly onto the Path object itself, returning an iterator of matching Path objects ā themselves fully-featured, ready for further pathlib operations, not plain strings needing to be re-wrapped.
Glob patterns support the same wildcard syntax most developers already know from shell usage: * matches any characters within one path segment, ** (used with rglob, or explicitly in a glob("**/pattern") call) matches across any number of directory levels, and ? matches a single character. test_*.py matches test_utils.py and test_core.py but not utils_test.py, letting you express fairly precise file-selection logic in one line instead of a filtering loop.
Because glob/rglob return generators (lazy, per the Generators lesson), wrapping the result in list(...) is only necessary when you need the full list ā for something like 'process every matching file one at a time', iterating the generator directly avoids materializing every path in memory at once, exactly the same efficiency benefit covered for generators generally.
from pathlib import Path
project = Path("src")
python_files = list(project.glob("*.py")) # top-level only
all_python_files = list(project.rglob("*.py")) # recursive, all subdirectories
test_files = list(project.rglob("test_*.py")) # recursive with a name patternEvery matching test file, from every subdirectory
2Decomposing a Path Without String Parsing
Extracting a filename's extension, its name without the extension, or its containing directory used to mean either os.path.splitext() (returning a tuple) or manual string slicing on \ or / ā both fragile relative to platform differences and easy to get subtly wrong (what about a file with no extension? multiple dots?). Path objects expose this decomposition as properties directly: .stem (the filename without its final suffix), .suffix (the final extension, including the dot), .suffixes (a list, for multi-extension names like archive.tar.gz), .parent (the containing directory, itself a Path), .name (the full filename with extension), and .parts (a tuple of every path component).
Because .parent returns another Path object rather than a string, it chains naturally ā p.parent.parent walks up two directory levels, and p.parent / "other_file.txt" builds a sibling path ā without ever needing to re-parse or re-wrap a string in between operations. This chaining is a recurring theme across pathlib's design: nearly every method and property that conceptually 'returns a path' returns an actual Path object, not a string, keeping the full method surface available at every step.
This eliminates a whole category of platform-specific string-parsing bugs (assuming / as a separator on a codebase that also needs to run on Windows, for instance) by construction ā Path's decomposition properties work correctly regardless of the underlying OS's path separator convention, since the object itself, not manual string logic, understands the platform's actual path syntax.
p = Path("/home/user/reports/2026/summary.csv")
print(p.stem) # 'summary' -- filename without extension
print(p.suffix) # '.csv'
print(p.parent) # PosixPath('/home/user/reports/2026')
print(p.parts) # ('/', 'home', 'user', 'reports', '2026', 'summary.csv')'summary', '.csv', PosixPath('/home/user/reports/2026')
3File Operations: Replacing os, shutil, and Manual open() Calls
pathlib extends well beyond path manipulation into the actual file operations that used to require os, shutil, or manual open()/close() pairs. Path.mkdir(parents=True, exist_ok=True) replaces os.makedirs(path, exist_ok=True); Path.write_text()/.read_text() and .write_bytes()/.read_bytes() replace an explicit open()/write()/close() sequence (or a with open(...) as f: block) for the common case of writing or reading an entire file's contents at once; .rename(), .unlink() (delete a file), and .exists()/.is_file()/.is_dir() cover what os.rename, os.remove, and os.path.exists/isfile/isdir used to require importing os for.
parents=True on mkdir() is the Path equivalent of os.makedirs() versus os.mkdir() ā it creates every missing intermediate directory in the path, not just the final one, so Path("output/2026/reports").mkdir(parents=True) succeeds even if neither output/ nor output/2026/ exist yet. exist_ok=True makes the call idempotent ā safe to run repeatedly (a common need in scripts that might be re-run) without raising FileExistsError if the directory is already there from a previous run.
The practical result: for the large majority of everyday file and directory manipulation in application code, pathlib alone ā without importing os, os.path, glob, or shutil separately ā covers what's needed, consolidating file-system interaction behind one consistent, chainable, object-oriented API instead of four separate modules with four different calling conventions.
output_dir = Path("output") / "2026" / "reports"
output_dir.mkdir(parents=True, exist_ok=True) # creates ALL missing parent dirs
report = output_dir / "summary.txt"
report.write_text("Report contents", encoding="utf-8")
report.rename(output_dir / "summary_final.txt")One consistent API instead of os + shutil + open()
4Step-by-Step Breakdown
pathlib isn't just os.path.join with nicer syntax ā it's a near-complete replacement for os, os.path, glob, and shutil's file-related functions. Let's use the whole thing.
Path.glob() and rglob() replace manual os.walk() loops for finding files matching a pattern.
Checkpoint: What is the difference between Path.glob("*.py") and Path.rglob("*.py")?
- ārglob searches recursively through all subdirectories; glob only searches the top level
- ārglob returns results sorted alphabetically; glob does not
Path objects decompose themselves ā .stem, .suffix, .parent, and .parts give you every piece without manual string splitting.
mkdir(parents=True, exist_ok=True) and the full read/write/rename/unlink method set cover nearly everything os and shutil used to be needed for.
Checkpoint: What does mkdir(parents=True, exist_ok=True) guard against?
- āMissing intermediate parent directories (parents=True) AND an error if the directory already exists (exist_ok=True)
- āInsufficient filesystem permissions to create the directory
pathlib covers the filesystem; collections covers the specialized data structures the standard library gives you beyond list/dict/set.
Reproduce Real glob vs rglob. Finish matches_pattern(): rglob searches recursively, glob only searches the top level.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Use rglob() with a specific pattern instead of os.walk() plus manual filename filtering
It expresses the same recursive search intent in one line, returns ready-to-use Path objects, and avoids manually reconstructing full paths from os.walk()'s (dirpath, dirnames, filenames) tuples.
Chain Path operations (parent, /, .with_suffix()) instead of round-tripping through strings
Converting a Path to a string mid-chain and back loses type safety and pathlib's cross-platform correctness guarantees for no benefit ā stay in Path objects until you genuinely need a string, e.g. for a legacy API.
Frequent Bugs
Using string concatenation or os.path.join with manually-typed separators to build a path, producing code that only works correctly on one operating system.
Use the / operator on Path objects (base_dir / "subdir" / "file.txt") which correctly handles the platform-appropriate separator automatically.
Real-World Examples
Finding and Archiving Old Log Files
A maintenance script needs to find every .log file older than 30 days across a nested logs directory and move them into an archive folder, preserving relative structure.
from pathlib import Path
import time
logs_dir = Path("var/logs")
archive_dir = Path("var/archive")
cutoff = time.time() - 30 * 86400
for log_file in logs_dir.rglob("*.log"):
if log_file.stat().st_mtime < cutoff:
destination = archive_dir / log_file.relative_to(logs_dir)
destination.parent.mkdir(parents=True, exist_ok=True)
log_file.rename(destination)