Six months ago, every Python file I wrote started with import os. Path joins, file checks, directory traversal—all through os.path. Then a senior engineer reviewed my code and asked: "Why aren't you using pathlib?"

I didn't have a good answer. I'd seen pathlib in docs but assumed it was just a different way to do the same thing. I was wrong. After switching, I can't imagine going back.

Why pathlib Over os.path?

The short answer: pathlib treats paths as objects, not strings.

Here's what that means in practice:

# os.path style
import os
 
base = "/home/user/projects"
project = os.path.join(base, "myapp")
config = os.path.join(project, "config", "settings.json")
if os.path.exists(config):
    with open(config) as f:
        data = f.read()
 
# pathlib style
from pathlib import Path
 
base = Path("/home/user/projects")
config = base / "myapp" / "config" / "settings.json"
if config.exists():
    data = config.read_text()

The pathlib version is shorter, clearer, and harder to mess up. But that's just the surface. Let me walk through everything.

Path Basics

Creating Paths

from pathlib import Path
 
# From a string
p = Path("/home/user/documents")
 
# Current directory
cwd = Path.cwd()
 
# Home directory
home = Path.home()
 
# Relative path
rel = Path("data/output.csv")
 
# From multiple parts
p = Path("home", "user", "file.txt")

Path Types

Python has two path types:

  • PurePath — Path operations without filesystem access
  • Path — Full filesystem operations (what you'll use 99% of the time)

And platform-specific variants:

  • PosixPath / PurePosixPath — Unix-style paths
  • WindowsPath / PureWindowsPath — Windows-style paths

Usually you just use Path and Python picks the right one for your OS.

from pathlib import Path, PurePosixPath
 
# Path() adapts to your OS
p = Path("/some/path")  # PosixPath on Unix, WindowsPath on Windows
 
# Force a specific style (for cross-platform path manipulation)
unix_path = PurePosixPath("/etc/config")

The / Operator

This is pathlib's killer feature. Forget os.path.join:

from pathlib import Path
 
base = Path("/var/log")
app_log = base / "myapp" / "app.log"
# PosixPath('/var/log/myapp/app.log')
 
# Works with strings on either side
log = base / "nginx" / "access.log"
log = "nginx" / base  # This also works!

No more forgetting whether arguments go in the right order. No more accidental double slashes. Just clean, readable path construction.

Getting Path Components

from pathlib import Path
 
p = Path("/home/user/projects/myapp/src/main.py")
 
# The filename
p.name           # 'main.py'
 
# Filename without extension
p.stem           # 'main'
 
# Extension (including the dot)
p.suffix         # '.py'
 
# All extensions (for files like 'archive.tar.gz')
Path("data.tar.gz").suffixes  # ['.tar', '.gz']
 
# Parent directory
p.parent         # PosixPath('/home/user/projects/myapp/src')
 
# All ancestors
list(p.parents)  
# [PosixPath('/home/user/projects/myapp/src'),
#  PosixPath('/home/user/projects/myapp'),
#  PosixPath('/home/user/projects'),
#  PosixPath('/home/user'),
#  PosixPath('/home'),
#  PosixPath('/')]
 
# Path parts as a tuple
p.parts          # ('/', 'home', 'user', 'projects', 'myapp', 'src', 'main.py')

Checking Path Properties

from pathlib import Path
 
p = Path("/home/user/file.txt")
 
p.is_absolute()  # True
p.is_relative_to("/home")  # True (Python 3.9+)
 
# Get the root
p.root           # '/'
p.anchor         # '/' (root + drive on Windows)
 
# Drive letter (Windows)
Path("C:/Users").drive  # 'C:' on Windows, '' on Unix
from pathlib import Path
 
p = Path("/home/user/projects/myapp")
 
# Go up one level
p.parent                    # PosixPath('/home/user/projects')
 
# Go up multiple levels
p.parent.parent             # PosixPath('/home/user')
 
# More readable: go up and back down
p.parent / "otherapp"       # PosixPath('/home/user/projects/otherapp')

File Operations

This is where pathlib shines. No more juggling os.path, os, shutil, and open().

Reading and Writing Files

from pathlib import Path
 
p = Path("config.json")
 
# Read entire file as string
content = p.read_text()
 
# Read as bytes
data = p.read_bytes()
 
# Write string to file (creates or overwrites)
p.write_text('{"key": "value"}')
 
# Write bytes
p.write_bytes(b"binary data")
 
# Specify encoding
p.read_text(encoding="utf-8")
p.write_text(content, encoding="utf-8")

Compare to the old way:

# Old way
with open("config.json", "r", encoding="utf-8") as f:
    content = f.read()
 
# pathlib way
content = Path("config.json").read_text(encoding="utf-8")

For large files or line-by-line processing, still use open():

from pathlib import Path
 
p = Path("large_file.txt")
 
# Path objects work with open()
with open(p) as f:
    for line in f:
        process(line)
 
# Or use the path's open() method
with p.open() as f:
    for line in f:
        process(line)

Creating Directories

from pathlib import Path
 
# Create a single directory
Path("new_dir").mkdir()
 
# Create nested directories (like mkdir -p)
Path("path/to/nested/dir").mkdir(parents=True)
 
# Don't error if it already exists
Path("maybe_exists").mkdir(exist_ok=True)
 
# Combine both
Path("path/to/dir").mkdir(parents=True, exist_ok=True)

The old way required separate imports and more code:

# Old way
import os
os.makedirs("path/to/dir", exist_ok=True)

Deleting Files and Directories

from pathlib import Path
 
# Delete a file
Path("temp.txt").unlink()
 
# Don't error if missing (Python 3.8+)
Path("maybe_missing.txt").unlink(missing_ok=True)
 
# Delete an empty directory
Path("empty_dir").rmdir()

For non-empty directories, you still need shutil:

import shutil
from pathlib import Path
 
shutil.rmtree(Path("dir_with_contents"))

Renaming and Moving

from pathlib import Path
 
p = Path("old_name.txt")
 
# Rename (returns new Path)
new = p.rename("new_name.txt")
 
# Move to different directory
new = p.rename(Path("archive") / p.name)
 
# Replace (overwrites if target exists)
p.replace("existing_file.txt")

Checking Existence and Type

from pathlib import Path
 
p = Path("/some/path")
 
p.exists()       # Does it exist at all?
p.is_file()      # Is it a regular file?
p.is_dir()       # Is it a directory?
p.is_symlink()   # Is it a symbolic link?
p.is_mount()     # Is it a mount point?

Getting File Info

from pathlib import Path
import datetime
 
p = Path("myfile.txt")
 
# File stats
stat = p.stat()
stat.st_size      # Size in bytes
stat.st_mtime     # Modification time (timestamp)
 
# Readable modification time
mtime = datetime.datetime.fromtimestamp(p.stat().st_mtime)
 
# For symlinks, stat the link itself (not target)
p.lstat()

Globbing

Finding files with patterns is where I first realized pathlib's power.

Basic Globbing

from pathlib import Path
 
# All Python files in a directory
for p in Path("src").glob("*.py"):
    print(p)
 
# All Python files recursively
for p in Path("src").rglob("*.py"):
    print(p)
 
# Specific pattern
for p in Path(".").glob("test_*.py"):
    print(p)
 
# Multiple extensions using iteration
for p in Path(".").glob("*"):
    if p.suffix in {".py", ".txt", ".md"}:
        print(p)

Glob Patterns

from pathlib import Path
 
# ? matches single character
list(Path(".").glob("file?.txt"))  # file1.txt, fileA.txt
 
# * matches anything except /
list(Path(".").glob("*.py"))
 
# ** matches any number of directories (recursive)
list(Path(".").glob("**/*.py"))    # Same as rglob("*.py")
 
# [seq] matches any character in seq
list(Path(".").glob("file[0-9].txt"))  # file0.txt through file9.txt

Practical Examples

from pathlib import Path
 
# Find all config files
configs = list(Path(".").rglob("*.config.*"))
 
# Find all test files
tests = list(Path("tests").rglob("test_*.py"))
 
# Sum size of all Python files
total_size = sum(p.stat().st_size for p in Path(".").rglob("*.py"))
print(f"Total Python code: {total_size / 1024:.1f} KB")
 
# Find files modified today
import datetime
today = datetime.date.today()
recent = [
    p for p in Path(".").rglob("*.py")
    if datetime.date.fromtimestamp(p.stat().st_mtime) == today
]

Path Manipulation

Changing Extensions and Names

from pathlib import Path
 
p = Path("data/report.csv")
 
# Change extension
p.with_suffix(".json")      # PosixPath('data/report.json')
p.with_suffix("")           # PosixPath('data/report') - remove extension
 
# Change filename
p.with_name("summary.csv")  # PosixPath('data/summary.csv')
 
# Change stem (name without extension)
p.with_stem("analysis")     # PosixPath('data/analysis.csv') (Python 3.9+)

Resolving and Normalizing

from pathlib import Path
 
# Resolve to absolute path (follows symlinks)
Path("../relative/path").resolve()
 
# Resolve without following symlinks (Python 3.9+)
Path("link").resolve(strict=False)
 
# Make relative to another path
p = Path("/home/user/projects/app/src/main.py")
p.relative_to("/home/user/projects")  # PosixPath('app/src/main.py')
 
# Expand ~ to home directory
Path("~/documents").expanduser()  # PosixPath('/home/user/documents')

Joining with Arbitrary Depth

from pathlib import Path
 
# Build paths from lists
parts = ["home", "user", "documents", "report.pdf"]
p = Path(*parts)  # PosixPath('home/user/documents/report.pdf')
 
# Or use joinpath for multiple parts
base = Path("/var/log")
p = base.joinpath("nginx", "access.log")

Comparison with os.path

Here's a side-by-side for common operations:

Operationos.pathpathlib
Join pathsos.path.join(a, b)Path(a) / b
Get filenameos.path.basename(p)p.name
Get directoryos.path.dirname(p)p.parent
Get extensionos.path.splitext(p)[1]p.suffix
Check existsos.path.exists(p)p.exists()
Is file?os.path.isfile(p)p.is_file()
Is directory?os.path.isdir(p)p.is_dir()
Absolute pathos.path.abspath(p)p.resolve()
Get sizeos.path.getsize(p)p.stat().st_size
Current diros.getcwd()Path.cwd()
Home diros.path.expanduser("~")Path.home()
Read fileopen(p).read()Path(p).read_text()
List directoryos.listdir(p)p.iterdir()
Find filesglob.glob(pattern)p.glob(pattern)
Make directoryos.makedirs(p)p.mkdir(parents=True)

The pathlib versions are consistently more readable, and they're methods on the path object rather than functions that take a path.

Common Patterns

Script's Directory

from pathlib import Path
 
# Get the directory containing this script
SCRIPT_DIR = Path(__file__).resolve().parent
 
# Load a config file relative to script
config = SCRIPT_DIR / "config.json"

Temporary Files with Cleanup

from pathlib import Path
import tempfile
 
# Create a temp directory that cleans up automatically
with tempfile.TemporaryDirectory() as tmp:
    tmp_path = Path(tmp)
    data_file = tmp_path / "data.json"
    data_file.write_text('{"temp": true}')
    # File exists here
# Directory and contents are deleted

Processing All Files

from pathlib import Path
 
def process_directory(directory: Path) -> None:
    for item in directory.iterdir():
        if item.is_file():
            process_file(item)
        elif item.is_dir():
            process_directory(item)  # Recursive

Safe File Operations

from pathlib import Path
 
def safe_write(path: Path, content: str) -> None:
    """Write to a temp file then rename (atomic on most systems)."""
    temp = path.with_suffix(".tmp")
    temp.write_text(content)
    temp.rename(path)
 
def backup_and_write(path: Path, content: str) -> None:
    """Create a backup before overwriting."""
    if path.exists():
        backup = path.with_suffix(path.suffix + ".bak")
        path.rename(backup)
    path.write_text(content)

Finding Project Root

from pathlib import Path
 
def find_project_root(marker: str = "pyproject.toml") -> Path:
    """Find project root by looking for a marker file."""
    current = Path.cwd()
    for parent in [current, *current.parents]:
        if (parent / marker).exists():
            return parent
    raise FileNotFoundError(f"Could not find {marker}")

Type Hints

from pathlib import Path
 
def load_config(config_path: Path) -> dict:
    """Load and parse a JSON config file."""
    import json
    return json.loads(config_path.read_text())
 
def save_output(data: str, output_dir: Path, filename: str) -> Path:
    """Save data to a file, return the path."""
    output_dir.mkdir(parents=True, exist_ok=True)
    output_path = output_dir / filename
    output_path.write_text(data)
    return output_path

When You Still Need os

pathlib doesn't replace everything. You'll still reach for os and friends for:

import os
import shutil
from pathlib import Path
 
# Environment variables
os.environ["HOME"]
 
# Changing current directory
os.chdir(Path("somewhere"))
 
# File permissions
os.chmod(Path("script.sh"), 0o755)
 
# Delete non-empty directories
shutil.rmtree(Path("dir_with_stuff"))
 
# Copy files
shutil.copy(Path("src.txt"), Path("dst.txt"))
shutil.copytree(Path("src_dir"), Path("dst_dir"))

The Switch

Here's how I migrated my codebase:

  1. Search and replace: import os → check each usage
  2. Start with new code: Use pathlib in new files, get comfortable
  3. Refactor file-by-file: Convert when you touch a file anyway
  4. Update function signatures: Accept Path objects, not strings

The backwards compatibility is good—most functions that expect strings work fine with Path objects (they call str() on them). But for new code, I use Path everywhere.

Conclusion

I spent years writing os.path.join() when I could have been writing /. The syntax alone is worth the switch. But it's the whole package—methods on objects, built-in globbing, clean file operations—that makes pathlib the right choice for modern Python.

If you're still on os.path, try pathlib for a week. You won't go back.


Have a favorite pathlib pattern I missed? I'm always looking for new tricks.

React to this post: