pathlib instead of os.path: modern file path handling
1. Overview
Much older Python code handles paths using os.path.join, os.path.splitext, os.listdir… These functions take and return strings, so code gets long, hard to read, and error-prone. The pathlib module (available since Python 3.4) represents paths as Path objects with methods for joining, splitting, reading, writing, and traversing files.
After reading this article, you’ll be able to convert most os.path code to pathlib and know where pitfalls hide. Examples use Python 3.12.
2. Creating Path Objects
Path is the starting point for all operations:
from pathlib import Path
reports = Path("data/reports")
home = Path.home()
cwd = Path.cwd()
print(reports)
print(type(reports))
print(home)
data/reports
<class 'pathlib.PosixPath'>
/home/minh
Explanation:
Path("data/reports")creates a path object. At this point, no disk operations happen. The directory doesn’t need to exist.- On Linux/macOS,
PathreturnsPosixPath. On Windows, it returnsWindowsPath. You don’t need to care about the specific class. Path.home()andPath.cwd()replaceos.path.expanduser("~")andos.getcwd().
If you only need to manipulate path strings without touching real files, use PurePath, PurePosixPath, or PureWindowsPath. This is useful for handling Windows paths while running on Linux.
3. Joining Paths with the / Operator
This is the most obvious change. Instead of nesting os.path.join, use the / operator:
import os
from pathlib import Path
# Old way
old = os.path.join("data", "orders", "2026", "order_1001.json")
# New way
base = Path("data")
new = base / "orders" / "2026" / "order_1001.json"
print(old)
print(new)
print(new == Path(old))
data/orders/2026/order_1001.json
data/orders/2026/order_1001.json
True
The / operator accepts both Path and str on the right side. The result is always Path, so you can keep joining. The separator changes automatically by OS: / on Linux, \ on Windows.
If the number of components changes at runtime, use joinpath:
parts = ["orders", "2026", "order_1001.json"]
path = Path("data").joinpath(*parts)
print(path) # data/orders/2026/order_1001.json
4. Splitting Path Components
os.path needs multiple functions for this: basename, dirname, splitext. With pathlib, everything is an attribute:
from pathlib import Path
invoice = Path("/srv/shop/invoices/2026/invoice_042.final.pdf")
print(invoice.name) # full filename
print(invoice.stem) # name without final extension
print(invoice.suffix) # final extension
print(invoice.suffixes) # all extensions
print(invoice.parent) # containing directory
print(invoice.parents[1])
print(invoice.parts)
invoice_042.final.pdf
invoice_042.final
.pdf
['.final', '.pdf']
/srv/shop/invoices/2026
/srv/shop/invoices
('/', 'srv', 'shop', 'invoices', '2026', 'invoice_042.final.pdf')
Explanation:
stemandsuffixonly split at the last dot. For.tar.gzfiles, usesuffixes.parentis equivalent toos.path.dirname.parentsis a sequence of parent directories, going up to the root.partssplits the path into a tuple, useful when comparing each level.
To create a new path with a different name or extension, use the with_* methods:
invoice = Path("invoices/invoice_042.pdf")
print(invoice.with_suffix(".xml")) # invoices/invoice_042.xml
print(invoice.with_stem("invoice_043")) # invoices/invoice_043.pdf
print(invoice.with_name("summary.csv")) # invoices/summary.csv
These methods don’t rename files on disk. They just return a new Path, because Path is immutable.
5. Reading and Writing Files
For small files, you don’t need open():
from pathlib import Path
config = Path("app_config.txt")
config.write_text("currency=VND\ntimeout=30\n", encoding="utf-8")
content = config.read_text(encoding="utf-8")
print(content.splitlines())
logo = Path("logo.bin")
logo.write_bytes(b"\x89PNG")
print(logo.read_bytes())
['currency=VND', 'timeout=30']
b'\x89PNG'
write_textopens the file, writes it, then closes. The old file is overwritten.read_textreads the entire content as a string.write_bytesandread_byteswork similarly with binary data.
For large files or when you need to read line by line, still use a context manager. Path has a built-in open method:
with Path("orders.csv").open("w", encoding="utf-8") as f:
f.write("id,amount\n1001,250000\n1002,99000\n")
with Path("orders.csv").open(encoding="utf-8") as f:
total = sum(int(line.split(",")[1]) for line in f if not line.startswith("id"))
print(total) # 349000
Note: Always pass
encoding="utf-8". If omitted, Python uses the system’s default encoding. On Windows, it’s usuallycp1252, so Vietnamese data will fail.
6. Checking and Traversing Directories
6.1. Checking Status
from pathlib import Path
p = Path("orders.csv")
print(p.exists(), p.is_file(), p.is_dir())
print(p.stat().st_size)
print(p.resolve())
True True False
36
/home/minh/project/orders.csv
resolve() returns the absolute path and resolves symlinks. It replaces os.path.abspath and os.path.realpath.
6.2. Listing and Finding Files
from pathlib import Path
root = Path("shop")
(root / "orders" / "2026").mkdir(parents=True, exist_ok=True)
(root / "orders" / "2026" / "o1.json").write_text("{}", encoding="utf-8")
(root / "orders" / "o0.json").write_text("{}", encoding="utf-8")
(root / "readme.md").write_text("# Shop", encoding="utf-8")
print(sorted(p.name for p in root.iterdir()))
print(sorted(str(p) for p in root.glob("*.md")))
print(sorted(str(p) for p in root.rglob("*.json")))
['orders', 'readme.md']
['shop/readme.md']
['shop/orders/2026/o1.json', 'shop/orders/o0.json']
iterdir()replacesos.listdir(). It returns fullPathobjects, not just filenames, so you don’t need to rebuild them.glob("*.md")searches only in the current directory.rglob("*.json")searches recursively, equivalent toglob("**/*.json").
Starting from Python 3.12, Path.walk() can replace os.walk():
for dirpath, dirnames, filenames in Path("shop").walk():
print(dirpath, filenames)
7. Creating, Renaming, and Deleting
import shutil
from pathlib import Path
backup = Path("backup/daily")
backup.mkdir(parents=True, exist_ok=True)
src = Path("orders.csv")
dst = src.rename(backup / "orders_2026-10-17.csv")
print(dst)
dst.unlink()
Path("not_there.txt").unlink(missing_ok=True)
backup.rmdir()
shutil.rmtree("backup")
backup/daily/orders_2026-10-17.csv
mkdir(parents=True, exist_ok=True)is equivalent toos.makedirs(..., exist_ok=True).renamereturns a newPath. On Windows,renameraises an error if the destination already exists. To overwrite on all platforms, usereplace.unlinkdeletes a file. Withmissing_ok=True, it doesn’t raise an error if the file doesn’t exist.rmdircan only delete empty directories. To delete a whole tree, you still needshutil.rmtree. This function acceptsPathnormally.
8. Quick Reference Table
| os / os.path | pathlib |
|---|---|
os.path.join(a, b) |
Path(a) / b |
os.path.basename(p) |
p.name |
os.path.dirname(p) |
p.parent |
os.path.splitext(p) |
p.stem, p.suffix |
os.path.exists(p) |
p.exists() |
os.path.isfile(p) / isdir |
p.is_file() / p.is_dir() |
os.path.abspath(p) |
p.resolve() or p.absolute() |
os.path.expanduser("~") |
Path.home() or p.expanduser() |
os.getcwd() |
Path.cwd() |
os.listdir(p) |
p.iterdir() |
glob.glob("**/*.py", recursive=True) |
p.rglob("*.py") |
os.makedirs(p, exist_ok=True) |
p.mkdir(parents=True, exist_ok=True) |
os.remove(p) |
p.unlink() |
os.rename(a, b) |
a.rename(b) |
os.path.getsize(p) |
p.stat().st_size |
os.path.relpath(p, start) |
p.relative_to(start) |
9. Writing Tests with pytest
The tmp_path fixture in pytest returns a Path, so test code using pathlib is very concise. Suppose you have a function that collects invoice PDF files into an archive directory:
# archive.py
from pathlib import Path
def archive_invoices(source: Path, target: Path) -> list[Path]:
target.mkdir(parents=True, exist_ok=True)
moved = []
for pdf in sorted(source.rglob("*.pdf")):
moved.append(pdf.rename(target / pdf.name))
return moved
Corresponding test:
# test_archive.py
from pathlib import Path
from archive import archive_invoices
def test_archive_moves_only_pdf_files(tmp_path: Path):
source = tmp_path / "inbox"
(source / "2026").mkdir(parents=True)
(source / "a.pdf").write_bytes(b"%PDF")
(source / "2026" / "b.pdf").write_bytes(b"%PDF")
(source / "note.txt").write_text("skip me", encoding="utf-8")
target = tmp_path / "archive"
moved = archive_invoices(source, target)
assert [p.name for p in moved] == ["b.pdf", "a.pdf"]
assert sorted(p.name for p in target.iterdir()) == ["a.pdf", "b.pdf"]
assert (source / "note.txt").exists()
assert not (source / "a.pdf").exists()
pytest -q test_archive.py
. [100%]
1 passed in 0.02s
The order in moved is b.pdf before a.pdf. Why? sorted compares Path objects, and inbox/2026/b.pdf comes before inbox/a.pdf because the character 2 is less than a. This detail is easy to miss when switching from comparing filename strings to comparing Path objects.
10. Common Mistakes
Adding Path to a string. Path("data") + "/x.txt" raises TypeError. Use / instead of +. If you really need to append to the filename, use p.with_name(p.name + "_old").
Right side is an absolute path. The result might surprise you:
from pathlib import Path
print(Path("/srv/uploads") / "/etc/passwd") # /etc/passwd
An absolute component on the right replaces the entire left part. If the filename comes from user input, you must check it first:
base = Path("/srv/uploads").resolve()
candidate = (base / user_input).resolve()
if not candidate.is_relative_to(base):
raise ValueError("Path is invalid")
with_suffix missing the dot. p.with_suffix("pdf") raises ValueError. You must write ".pdf".
Forgetting that glob returns a generator. You can iterate it only once. If you need to reuse it or call len(), wrap it in list(...). The result order is not guaranteed either, so use sorted when order matters.
Hidden files and stem. With Path(".env"), suffix is an empty string and stem is ".env". Don’t assume every file has an extension.
Old libraries only accept str. Most standard APIs have accepted os.PathLike since Python 3.6. If you encounter an old library, use str(p) or os.fspath(p).
Note:
pathlibdoesn’t completely replaceosandshutil. To copy files, you still useshutil.copy2(Python 3.14 addsPath.copy). Environment variables are still read viaos.environ. Complex permissions still needos.chmodoros.access.
When not to change? For loops processing millions of path strings without touching disk, creating Path objects is slower than pure string operations. This is rare, and you should measure before optimizing. With old code running well, you don’t need to change everything at once. Migrate gradually as you touch each module.
11. Conclusion
pathlib brings scattered operations from os, os.path, and glob into a single object. Code is shorter, reads more naturally, and has fewer string-joining errors. You’ve seen how to join paths with /, split components with name, stem, suffix, parent, read and write files with read_text and write_text, traverse directories with iterdir, glob, rglob, walk, and test with pytest’s tmp_path.
Remember three points. Always pass encoding. Be careful when joining with absolute paths from external input. Still use shutil for operations like copying and deleting directory trees. For new code, default to Path and only go back to os.path when there’s a specific reason.