datarekha

pathlib

The modern, object-oriented path API — forget os.path.join, use the / operator and real methods, and stop debugging slashes on Windows.

8 min read Beginner Python Lesson 21 of 41

What you'll learn

  • Constructing Path objects and joining with the / operator
  • The properties — .name, .stem, .suffix, .parent
  • Inspecting paths — .exists(), .is_file(), .is_dir(), .mkdir()
  • .glob() and .rglob() for finding files, and .read_text()/.write_text()

Before you start

For most of Python’s life, working with paths meant a scattering of os.path.join, os.path.basename, and os.path.splitext calls — string surgery, essentially, with all the off-by-a-slash bugs that invites. Then pathlib arrived and made a path a real object, with methods and an operator, and quietly retired all of that. New code should default to pathlib; you will still meet the old string-based API in legacy code, but reaching for it in a new pull request now reads as a small red flag.

Path() and the slash operator

A Path is built from a string and joined with / — yes, the division operator. It looks odd for about ten seconds, and then it becomes the most natural thing in the world:

from pathlib import Path

root = Path("/tmp/datarekha")

# Join with / — portable, and it becomes a backslash on Windows automatically.
log_path = root / "logs" / "app.log"
print(log_path)

# Mix in variables freely.
user_id = 42
user_dir = root / "users" / str(user_id) / "profile.json"
print(user_dir)

# Convert to a plain string only when an old API demands one.
print(str(log_path))
/tmp/datarekha/logs/app.log
/tmp/datarekha/users/42/profile.json
/tmp/datarekha/logs/app.log

Path("/a") / "b" / "c" is Path("/a/b/c") — the same result as os.path.join("/a", "b", "c"), with a fraction of the noise, and it reads like the path it builds.

The properties that replace ten os.path functions

A Path exposes its pieces as plain attributes, which is where most of the old os.path zoo disappears:

from pathlib import Path

p = Path("/var/log/datarekha/app.log")

print("name:  ", p.name)      # filename with extension
print("stem:  ", p.stem)      # filename without extension
print("suffix:", p.suffix)    # extension, including the dot
print("parent:", p.parent)    # the containing directory
print("parts: ", p.parts)     # every component, as a tuple

sibling = p.parent / "audit.log"   # a file next to this one
print("sibling:", sibling)

renamed = p.with_suffix(".log.gz")  # swap the extension
print("renamed:", renamed)

moved = p.with_name("debug.log")    # swap the whole filename
print("moved:  ", moved)
name:   app.log
stem:   app
suffix: .log
parent: /var/log/datarekha
parts:  ('/', 'var', 'log', 'datarekha', 'app.log')
sibling: /var/log/datarekha/audit.log
renamed: /var/log/datarekha/app.log.gz
moved:   /var/log/datarekha/debug.log

The split between .name, .stem, and .suffix is worth fixing in your mind — it is the breakdown the rest of the API leans on:

p = Path(“/var/log/datarekha/app.log”).parent/var/log/datarekha.stemapp.suffix.logthe directory above.stem + .suffix = .nameapp.log = .name

.with_suffix() and .with_name() are the two methods you reach for constantly in build scripts and data pipelines — the classic “for each .csv, write a .parquet next to it.”

Existence and type checks

A Path can also ask the filesystem about itself, and create directories:

from pathlib import Path

Path("/tmp/example.txt").write_text("hi", encoding="utf-8")
Path("/tmp/example_dir").mkdir(exist_ok=True)

p_file = Path("/tmp/example.txt")
p_dir = Path("/tmp/example_dir")
p_missing = Path("/tmp/does_not_exist.txt")

print(p_file.exists(), p_file.is_file(), p_file.is_dir())
print(p_dir.exists(), p_dir.is_file(), p_dir.is_dir())
print(p_missing.exists())

Path("/tmp/nested/deep/dir").mkdir(parents=True, exist_ok=True)
print(Path("/tmp/nested/deep/dir").exists())
True True False
True False True
False
True

Two flags on mkdir do a lot of work: parents=True creates any missing parent directories, and exist_ok=True makes the call idempotent (no error if the directory is already there). You will type .mkdir(parents=True, exist_ok=True) so often it becomes muscle memory.

read_text and write_text — the one-liners

For small text files — configs, a single CSV, a prompt template — you can skip the with open(...) dance entirely:

from pathlib import Path

config_path = Path("/tmp/config.yaml")

config_path.write_text("model: gpt-4\ntemperature: 0.2\n", encoding="utf-8")

contents = config_path.read_text(encoding="utf-8")
print(contents)

# Binary equivalents, when you need raw bytes.
config_path.write_bytes(b"\x00\x01\x02")
print(config_path.read_bytes())
model: gpt-4
temperature: 0.2
b'\x00\x01\x02'

For large files, stay with open() and a with block — .read_text() pulls the whole file into memory exactly like .read() does.

glob and rglob — finding files

This pair is the engine of every “process all the X files under here” script. .glob matches one directory level; .rglob recurses the whole tree:

from pathlib import Path

root = Path("/tmp/myproj")
(root / "src").mkdir(parents=True, exist_ok=True)
(root / "tests").mkdir(parents=True, exist_ok=True)
(root / "setup.py").write_text("# setup", encoding="utf-8")
(root / "src" / "main.py").write_text("# main", encoding="utf-8")
(root / "src" / "utils.py").write_text("# utils", encoding="utf-8")
(root / "src" / "data.json").write_text("{}", encoding="utf-8")
(root / "tests" / "test_main.py").write_text("# test", encoding="utf-8")

# .glob is one level deep (sorted only so the output is stable to show).
print("top-level .py (glob):")
for p in sorted(root.glob("*.py")):
    print(" ", p)

# .rglob recurses the whole tree.
print("all .py anywhere (rglob):")
for p in sorted(root.rglob("*.py")):
    print(" ", p)
top-level .py (glob):
  /tmp/myproj/setup.py
all .py anywhere (rglob):
  /tmp/myproj/setup.py
  /tmp/myproj/src/main.py
  /tmp/myproj/src/utils.py
  /tmp/myproj/tests/test_main.py

The difference is plain in the output: glob("*.py") saw only setup.py at the top level, while rglob("*.py") walked into src/ and tests/ and found everything. rglob("*.py") is exactly glob("**/*.py"), and — the part that matters at scale — it returns a generator, streaming paths as it walks rather than building one giant list first. (We sorted the results purely so the printed order is stable; the raw order is filesystem-dependent.)

Finding files by modification time

A frequent request is “find files changed recently”. A Path gives you .stat().st_mtime — the modification time as a Unix timestamp — so the filter is a one-liner. Here we set fixed timestamps and a fixed cutoff so the result is exact; in real code the cutoff would be time.time() - 7 * 86400 for “the last seven days”:

from pathlib import Path
import os

root = Path("/tmp/myproj")
(root / "src").mkdir(parents=True, exist_ok=True)
(root / "src" / "old.py").write_text("# old", encoding="utf-8")
(root / "src" / "new.py").write_text("# new", encoding="utf-8")

# Stamp explicit modification times (Unix seconds) — no wall clock involved.
os.utime(root / "src" / "old.py", (1_700_000_000, 1_700_000_000))
os.utime(root / "src" / "new.py", (1_900_000_000, 1_900_000_000))

cutoff = 1_800_000_000          # keep only files modified after this instant
recent = [p for p in sorted(root.glob("src/*.py"))
          if p.stat().st_mtime >= cutoff]

print("modified after cutoff:")
for p in recent:
    print(" ", p.name, "->", int(p.stat().st_mtime))
modified after cutoff:
  new.py -> 1900000000

Only new.py cleared the cutoff; old.py, stamped earlier, was filtered out.

resolve(), home(), and the cwd

Path("config.yaml").resolve()       # absolute path, following symlinks
Path("config.yaml").absolute()      # absolute path, NOT following symlinks
Path.cwd()                          # current working directory
Path.home()                         # the user's home directory

.resolve() is the safe default for “give me the one true path to this file” — exactly what you want to print in a log line so whoever is debugging can find the file on disk.

In one breath

  • Build paths with Path(...) and join with the / operator — portable and readable.
  • Decompose with .name, .stem, .suffix, .parent; rewrite with .with_suffix() / .with_name().
  • Inspect with .exists(), .is_file(), .is_dir(); create with .mkdir(parents=True, exist_ok=True).
  • .glob is one level, .rglob recurses (a streaming generator); .read_text()/.write_text() are the small-file shortcuts.
  • .stat().st_mtime gives modification time for “find recent files” filters.

Practice

Quick check

0/3
Q1What does `Path('/tmp') / 'a' / 'b.csv'` return?
Q2What is the difference between `.glob('*.py')` and `.rglob('*.py')`?
Q3When should you NOT use `.read_text()`?

What’s next

pathlib finds files; next we look inside them. Regex is the language for finding and extracting patterns within text — the other half of nearly every data-cleaning job.

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Related lessons

Explore further

Cheat sheets
Skip to content