|$ curl https://forge-ai.dev/api/markdown?path=docs/python/pathlib
$cat docs/pathlib.md
updated Today·18-24 min read·published
pathlib
Introduction
pathlib provides object-oriented filesystem paths. Prefer it over os.path string munging for readability, safety, and cross-platform behavior.
ℹ
info
Always pass encoding="utf-8" to read_text/write_text unless you have a deliberate reason not to.
PurePath vs Path
PurePath / PurePosixPath / PureWindowsPath do path arithmetic without touching the filesystem. Path subclasses add I/O.
pure_vs_path.py
Python
| 1 | from pathlib import Path, PurePosixPath, PureWindowsPath |
| 2 | |
| 3 | # Pure — no I/O, useful for cross-platform formatting |
| 4 | posix = PurePosixPath("/var/log/app.log") |
| 5 | win = PureWindowsPath(r"C:\Users\ada\app.log") |
| 6 | print(posix.name, win.suffix) |
| 7 | |
| 8 | # Concrete Path — I/O methods available |
| 9 | p = Path("data") / "raw" / "file.csv" |
| 10 | print(p.as_posix()) # data/raw/file.csv |
| 11 | print(p.parts) # ('data', 'raw', 'file.csv') |
| 12 | print(p.parent, p.parents[1]) |
| 13 | print(p.stem, p.suffixes) # file, ['.csv'] |
| Class | I/O? | Use |
|---|---|---|
| PurePath | No | Parse/format paths |
| Path | Yes | Real filesystem |
| PosixPath | Yes | On Unix automatically |
| WindowsPath | Yes | On Windows automatically |
Construction & Joining
join.py
Python
| 1 | from pathlib import Path |
| 2 | |
| 3 | home = Path.home() |
| 4 | cwd = Path.cwd() |
| 5 | abs_p = Path("/tmp/demo") |
| 6 | rel = Path("src") / "app" / "main.py" |
| 7 | |
| 8 | # Expand user and env |
| 9 | print(Path("~/projects").expanduser()) |
| 10 | # Path("/tmp/$USER") — use os.path.expandvars if needed |
| 11 | |
| 12 | # Absolute vs resolve |
| 13 | print(rel.absolute()) # cwd-prefixed, may include .. |
| 14 | print(rel.resolve()) # absolute + normalize symlinks (strict=False default 3.6+) |
| 15 | print(rel.resolve(strict=True)) # FileNotFoundError if missing |
Reading & Writing
rw.py
Python
| 1 | from pathlib import Path |
| 2 | |
| 3 | p = Path("notes.txt") |
| 4 | p.write_text("hello\n", encoding="utf-8") |
| 5 | print(p.read_text(encoding="utf-8")) |
| 6 | |
| 7 | b = Path("blob.bin") |
| 8 | b.write_bytes(b"\x00\xff") |
| 9 | print(b.read_bytes()) |
| 10 | |
| 11 | # Open as file object |
| 12 | with p.open("a", encoding="utf-8") as f: |
| 13 | f.write("more\n") |
| 14 | |
| 15 | # Touch / mkdir |
| 16 | Path("logs").mkdir(parents=True, exist_ok=True) |
| 17 | Path("logs/app.log").touch(exist_ok=True) |
glob & rglob
globbing.py
Python
| 1 | from pathlib import Path |
| 2 | |
| 3 | root = Path("src") |
| 4 | # Non-recursive |
| 5 | print(list(root.glob("*.py"))) |
| 6 | # Recursive |
| 7 | print(list(root.rglob("test_*.py"))) |
| 8 | # Pattern segments |
| 9 | print(list(root.glob("**/*.tsx"))) |
| 10 | |
| 11 | # Filter directories / files |
| 12 | files = [p for p in root.rglob("*") if p.is_file()] |
| 13 | dirs = [p for p in root.iterdir() if p.is_dir()] |
⚠
warning
rglob on huge trees can be expensive. Consider limiting depth or using os.scandir for performance-critical walks.
resolve & relative_to
resolve_rel.py
Python
| 1 | from pathlib import Path |
| 2 | |
| 3 | root = Path("/var/app").resolve() |
| 4 | user_input = Path("/var/app/../../etc/passwd") |
| 5 | |
| 6 | def safe_join(root: Path, user: str) -> Path: |
| 7 | candidate = (root / user).resolve() |
| 8 | try: |
| 9 | candidate.relative_to(root) |
| 10 | except ValueError as e: |
| 11 | raise PermissionError("path escape") from e |
| 12 | return candidate |
| 13 | |
| 14 | # relative_to requires a shared ancestor after resolve |
| 15 | print(Path("/a/b/c").relative_to("/a")) # b/c |
| 16 | # Path("/a/b").relative_to("/x") # ValueError |
✕
danger
Always resolve() then relative_to(root) when accepting user-supplied path segments — classic path traversal defense.
Walking Trees
walk.py
Python
| 1 | from pathlib import Path |
| 2 | from collections.abc import Iterator |
| 3 | |
| 4 | def walk_files(root: Path, *, suffix: str | None = None) -> Iterator[Path]: |
| 5 | for p in root.rglob("*"): |
| 6 | if not p.is_file(): |
| 7 | continue |
| 8 | if suffix and p.suffix != suffix: |
| 9 | continue |
| 10 | yield p |
| 11 | |
| 12 | def disk_usage(root: Path) -> int: |
| 13 | return sum(p.stat().st_size for p in walk_files(root)) |
| 14 | |
| 15 | def newest(root: Path, n: int = 5) -> list[Path]: |
| 16 | files = sorted(walk_files(root), key=lambda p: p.stat().st_mtime, reverse=True) |
| 17 | return files[:n] |
| 18 | |
| 19 | # Replace a tree carefully |
| 20 | def clear_dir(d: Path) -> None: |
| 21 | if not d.is_dir(): |
| 22 | return |
| 23 | for child in d.iterdir(): |
| 24 | if child.is_file(): |
| 25 | child.unlink() |
| 26 | else: |
| 27 | clear_dir(child) |
| 28 | child.rmdir() |
Stat & Metadata
stat.py
Python
| 1 | from pathlib import Path |
| 2 | import os |
| 3 | |
| 4 | p = Path("readme.md") |
| 5 | if p.exists(): |
| 6 | st = p.stat() |
| 7 | print(st.st_size, st.st_mtime) |
| 8 | print(p.is_file(), p.is_dir(), p.is_symlink()) |
| 9 | print(oct(st.st_mode)) |
| 10 | |
| 11 | # chmod / rename / replace |
| 12 | # p.chmod(0o644) |
| 13 | # p.rename(p.with_suffix(".bak")) |
| 14 | # p.replace(Path("dest.md")) # overwrites dest |
| Method | Purpose |
|---|---|
| exists / is_file / is_dir | Presence checks |
| stat / lstat | Metadata (lstat no follow) |
| with_name / with_suffix | Derive sibling paths |
| match / full_match | Pattern test on path |
| samefile | Compare inodes |
Practical Recipes
recipes.py
Python
| 1 | from pathlib import Path |
| 2 | import tempfile |
| 3 | |
| 4 | def atomic_write(path: Path, text: str, encoding: str = "utf-8") -> None: |
| 5 | path.parent.mkdir(parents=True, exist_ok=True) |
| 6 | with tempfile.NamedTemporaryFile( |
| 7 | "w", delete=False, dir=path.parent, encoding=encoding |
| 8 | ) as tmp: |
| 9 | tmp.write(text) |
| 10 | tmp_path = Path(tmp.name) |
| 11 | tmp_path.replace(path) # atomic on same filesystem |
| 12 | |
| 13 | def find_project_root(start: Path | None = None, marker: str = "pyproject.toml") -> Path: |
| 14 | cur = (start or Path.cwd()).resolve() |
| 15 | for parent in (cur, *cur.parents): |
| 16 | if (parent / marker).is_file(): |
| 17 | return parent |
| 18 | raise FileNotFoundError(marker) |
| 19 | |
| 20 | assert isinstance(find_project_root(), Path) |
with_name, with_stem, with_suffix
with_methods.py
Python
| 1 | from pathlib import Path |
| 2 | |
| 3 | p = Path("archive/report.tar.gz") |
| 4 | print(p.with_suffix(".zip")) # report.tar.zip — only last suffix |
| 5 | print(p.with_name("other.tar.gz")) |
| 6 | print(p.with_stem("summary")) # summary.tar.gz (3.9+) |
| 7 | |
| 8 | # Strip all suffixes |
| 9 | q = Path("file.tar.gz") |
| 10 | while q.suffix: |
| 11 | q = q.with_suffix("") |
| 12 | print(q) # file |
Bytes, URIs & as_uri
uri.py
Python
| 1 | from pathlib import Path |
| 2 | |
| 3 | p = Path("/tmp/demo.txt").resolve() |
| 4 | print(p.as_uri()) # file:///tmp/demo.txt |
| 5 | # Path.from_uri on 3.13+; older: manual parse |
| 6 | |
| 7 | # Equality compares lexicographically; use samefile for inodes |
| 8 | a = Path("readme.md") |
| 9 | b = Path("./readme.md") |
| 10 | print(a == b) |
| 11 | if a.exists() and b.exists(): |
| 12 | print(a.samefile(b)) |
Filtering Walks
filter_walk.py
Python
| 1 | from pathlib import Path |
| 2 | |
| 3 | IGNORE = {".git", ".venv", "node_modules", "__pycache__", ".mypy_cache"} |
| 4 | |
| 5 | def iter_source(root: Path): |
| 6 | for p in root.rglob("*"): |
| 7 | if any(part in IGNORE for part in p.parts): |
| 8 | continue |
| 9 | if p.is_file() and p.suffix in {".py", ".pyi"}: |
| 10 | yield p |
| 11 | |
| 12 | for f in iter_source(Path(".")): |
| 13 | print(f) |
pathlib vs os.path
| os.path | pathlib |
|---|---|
| os.path.join(a,b) | Path(a) / b |
| os.path.basename(p) | Path(p).name |
| os.path.splitext(p) | Path(p).suffix / .stem |
| os.path.exists(p) | Path(p).exists() |
| os.listdir(p) | Path(p).iterdir() |
| glob.glob(pat) | Path().glob(pat) |
Migrate incrementally: wrap strings in Path at boundaries, then use Path methods throughout.
$Blueprint — Engineering Documentation·Section ID: PYTHON-PATHLIB·Revision: 1.0
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.