|$ curl https://forge-ai.dev/api/markdown?path=docs/python/pathlib
$cat docs/pathlib.md
updated Today·18-24 min read·published

pathlib

PythonpathlibFilesystemIntermediate🎯Free Tools
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
1from pathlib import Path, PurePosixPath, PureWindowsPath
2
3# Pure — no I/O, useful for cross-platform formatting
4posix = PurePosixPath("/var/log/app.log")
5win = PureWindowsPath(r"C:\Users\ada\app.log")
6print(posix.name, win.suffix)
7
8# Concrete Path — I/O methods available
9p = Path("data") / "raw" / "file.csv"
10print(p.as_posix()) # data/raw/file.csv
11print(p.parts) # ('data', 'raw', 'file.csv')
12print(p.parent, p.parents[1])
13print(p.stem, p.suffixes) # file, ['.csv']
ClassI/O?Use
PurePathNoParse/format paths
PathYesReal filesystem
PosixPathYesOn Unix automatically
WindowsPathYesOn Windows automatically
Construction & Joining
join.py
Python
1from pathlib import Path
2
3home = Path.home()
4cwd = Path.cwd()
5abs_p = Path("/tmp/demo")
6rel = Path("src") / "app" / "main.py"
7
8# Expand user and env
9print(Path("~/projects").expanduser())
10# Path("/tmp/$USER") — use os.path.expandvars if needed
11
12# Absolute vs resolve
13print(rel.absolute()) # cwd-prefixed, may include ..
14print(rel.resolve()) # absolute + normalize symlinks (strict=False default 3.6+)
15print(rel.resolve(strict=True)) # FileNotFoundError if missing
Reading & Writing
rw.py
Python
1from pathlib import Path
2
3p = Path("notes.txt")
4p.write_text("hello\n", encoding="utf-8")
5print(p.read_text(encoding="utf-8"))
6
7b = Path("blob.bin")
8b.write_bytes(b"\x00\xff")
9print(b.read_bytes())
10
11# Open as file object
12with p.open("a", encoding="utf-8") as f:
13 f.write("more\n")
14
15# Touch / mkdir
16Path("logs").mkdir(parents=True, exist_ok=True)
17Path("logs/app.log").touch(exist_ok=True)
glob & rglob
globbing.py
Python
1from pathlib import Path
2
3root = Path("src")
4# Non-recursive
5print(list(root.glob("*.py")))
6# Recursive
7print(list(root.rglob("test_*.py")))
8# Pattern segments
9print(list(root.glob("**/*.tsx")))
10
11# Filter directories / files
12files = [p for p in root.rglob("*") if p.is_file()]
13dirs = [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
1from pathlib import Path
2
3root = Path("/var/app").resolve()
4user_input = Path("/var/app/../../etc/passwd")
5
6def 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
15print(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
1from pathlib import Path
2from collections.abc import Iterator
3
4def 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
12def disk_usage(root: Path) -> int:
13 return sum(p.stat().st_size for p in walk_files(root))
14
15def 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
20def 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
1from pathlib import Path
2import os
3
4p = Path("readme.md")
5if 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
MethodPurpose
exists / is_file / is_dirPresence checks
stat / lstatMetadata (lstat no follow)
with_name / with_suffixDerive sibling paths
match / full_matchPattern test on path
samefileCompare inodes
Practical Recipes
recipes.py
Python
1from pathlib import Path
2import tempfile
3
4def 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
13def 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
20assert isinstance(find_project_root(), Path)
with_name, with_stem, with_suffix
with_methods.py
Python
1from pathlib import Path
2
3p = Path("archive/report.tar.gz")
4print(p.with_suffix(".zip")) # report.tar.zip — only last suffix
5print(p.with_name("other.tar.gz"))
6print(p.with_stem("summary")) # summary.tar.gz (3.9+)
7
8# Strip all suffixes
9q = Path("file.tar.gz")
10while q.suffix:
11 q = q.with_suffix("")
12print(q) # file
Bytes, URIs & as_uri
uri.py
Python
1from pathlib import Path
2
3p = Path("/tmp/demo.txt").resolve()
4print(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
8a = Path("readme.md")
9b = Path("./readme.md")
10print(a == b)
11if a.exists() and b.exists():
12 print(a.samefile(b))
Filtering Walks
filter_walk.py
Python
1from pathlib import Path
2
3IGNORE = {".git", ".venv", "node_modules", "__pycache__", ".mypy_cache"}
4
5def 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
12for f in iter_source(Path(".")):
13 print(f)
pathlib vs os.path
os.pathpathlib
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.