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, Path returns PosixPath. On Windows, it returns WindowsPath. You don’t need to care about the specific class.
  • Path.home() and Path.cwd() replace os.path.expanduser("~") and os.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:

  • stem and suffix only split at the last dot. For .tar.gz files, use suffixes.
  • parent is equivalent to os.path.dirname. parents is a sequence of parent directories, going up to the root.
  • parts splits 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_text opens the file, writes it, then closes. The old file is overwritten.
  • read_text reads the entire content as a string.
  • write_bytes and read_bytes work 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 usually cp1252, 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() replaces os.listdir(). It returns full Path objects, not just filenames, so you don’t need to rebuild them.
  • glob("*.md") searches only in the current directory.
  • rglob("*.json") searches recursively, equivalent to glob("**/*.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 to os.makedirs(..., exist_ok=True).
  • rename returns a new Path. On Windows, rename raises an error if the destination already exists. To overwrite on all platforms, use replace.
  • unlink deletes a file. With missing_ok=True, it doesn’t raise an error if the file doesn’t exist.
  • rmdir can only delete empty directories. To delete a whole tree, you still need shutil.rmtree. This function accepts Path normally.

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: pathlib doesn’t completely replace os and shutil. To copy files, you still use shutil.copy2 (Python 3.14 adds Path.copy). Environment variables are still read via os.environ. Complex permissions still need os.chmod or os.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.

pythonpathlibos.pathfile-iopython3