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 accessPath— Full filesystem operations (what you'll use 99% of the time)
And platform-specific variants:
PosixPath/PurePosixPath— Unix-style pathsWindowsPath/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.
Navigation: parent, parts, stem, suffix
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 UnixNavigating Up and Down
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.txtPractical 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:
| Operation | os.path | pathlib |
|---|---|---|
| Join paths | os.path.join(a, b) | Path(a) / b |
| Get filename | os.path.basename(p) | p.name |
| Get directory | os.path.dirname(p) | p.parent |
| Get extension | os.path.splitext(p)[1] | p.suffix |
| Check exists | os.path.exists(p) | p.exists() |
| Is file? | os.path.isfile(p) | p.is_file() |
| Is directory? | os.path.isdir(p) | p.is_dir() |
| Absolute path | os.path.abspath(p) | p.resolve() |
| Get size | os.path.getsize(p) | p.stat().st_size |
| Current dir | os.getcwd() | Path.cwd() |
| Home dir | os.path.expanduser("~") | Path.home() |
| Read file | open(p).read() | Path(p).read_text() |
| List directory | os.listdir(p) | p.iterdir() |
| Find files | glob.glob(pattern) | p.glob(pattern) |
| Make directory | os.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 deletedProcessing 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) # RecursiveSafe 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_pathWhen 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:
- Search and replace:
import os→ check each usage - Start with new code: Use pathlib in new files, get comfortable
- Refactor file-by-file: Convert when you touch a file anyway
- Update function signatures: Accept
Pathobjects, 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.