pathlib
pathlib represents filesystem paths as objects instead of strings, giving you readable path joining, built-in existence and type checks, and platform-independent behavior in place of manual os.path string manipulation.
Before pathlib (introduced in Python 3.4), filesystem paths were handled as plain strings joined and split with functions from os.path -- functionally complete, but easy to get subtly wrong across platforms and verbose for common tasks. pathlib.Path represents a path as a real object with methods and operators, and is the modern, idiomatic choice for new code.
What Is pathlib and Why It Replaces os.path
pathlib.Path wraps a filesystem path string in an object with methods for the operations you'd otherwise do by importing several os.path functions individually (join, exists, isfile, splitext, and more). The result reads closer to natural language and, importantly, handles path-separator differences between operating systems for you.
from pathlib import Path
# os.path style (still valid, more verbose)
import os.path
old_style = os.path.join('data', 'reports', 'summary.txt')
# pathlib style (preferred for new code)
p = Path('data') / 'reports' / 'summary.txt'
print(p) # data/reports/summary.txt (or data\reports\summary.txt on Windows)Creating Path Objects
Path(*parts) constructs a path from one or more string segments. Path.cwd() returns the current working directory; Path.home() returns the user's home directory. Both return Path objects, not strings, so they compose with the rest of the API directly.
from pathlib import Path
p = Path('folder', 'file.txt')
print(p) # folder/file.txt
print(Path.cwd()) # e.g. /home/user/project
print(Path.home()) # e.g. /home/userJoining Paths with /
Path overloads the / operator specifically for path joining -- this is not integer/float division, it's Path.__truediv__ repurposed to build paths. Joining with / always inserts the correct platform-native separator, so code written this way behaves correctly on Linux, macOS, and Windows without any conditional logic.
You can mix strings and Path objects freely on either side of / -- as long as at least one operand is a Path, Python dispatches to Path's overloaded operator.
base = Path('/var/log')
full = base / 'app' / 'errors.log'
print(full) # /var/log/app/errors.logPath Components: name, stem, suffix, parent, parts
A Path exposes its pieces as read-only properties, without any string slicing: name is the final component (file or folder name with extension), stem is name without the final suffix, suffix is the final extension (including the dot), parent is the containing directory as another Path, and parts is a tuple of every component.
stem and suffix only ever split off the single final extension -- 'report.final.csv' has suffix '.csv' and stem 'report.final', not 'report'.
p = Path('/home/user/report.final.csv')
print(p.name) # 'report.final.csv'
print(p.stem) # 'report.final' -- only the LAST suffix is stripped
print(p.suffix) # '.csv'
print(p.suffixes) # ['.final', '.csv'] -- all suffixes, if there are several
print(p.parent) # /home/user
print(p.parts) # ('/', 'home', 'user', 'report.final.csv')Checking Existence and Type
exists() reports whether the path refers to anything on disk at all. is_file() and is_dir() narrow that down to a specific type. All three return False (not an error) for a path that doesn't exist, and all three make a system call each time, so avoid calling them redundantly in a tight loop.
p = Path('data.csv')
if p.exists() and p.is_file():
print(f'{p} is a real file, {p.stat().st_size} bytes')
else:
print(f'{p} not found')Reading and Writing Files via Path
read_text(encoding=...) and write_text(data, encoding=...) read/write an entire text file in one call, wrapping open()/close() internally. read_bytes() and write_bytes(data) do the same for binary content. Path.open(mode, encoding=...) returns a regular file object (usable with with) when you need streaming access instead of loading everything at once.
read_text/write_text are convenient for whole-file operations; for large files or line-by-line processing, Path.open() gives you the same file object behavior covered in the File Handling topic.
p = Path('notes.txt')
p.write_text('hello from pathlib\n', encoding='utf-8')
print(p.read_text(encoding='utf-8')) # hello from pathlib
with p.open('a', encoding='utf-8') as f: # streaming, when needed
f.write('appended line\n')Creating and Removing Directories
mkdir(parents=False, exist_ok=False) creates a directory; parents=True also creates any missing intermediate directories (like mkdir -p), and exist_ok=True suppresses the FileExistsError that would otherwise be raised if the directory is already there. rmdir() removes an empty directory (raises OSError if it isn't empty).
output_dir = Path('build') / 'reports' / '2026'
output_dir.mkdir(parents=True, exist_ok=True) # creates build/, build/reports/, and this dir
print(output_dir.is_dir()) # TrueListing and Globbing
iterdir() lazily yields every direct child of a directory as a Path. glob(pattern) matches direct or nested children against a shell-style wildcard pattern (* for any characters within one segment, for recursive matching across directories when combined with rglob or a '/ ' pattern). rglob(pattern) is shorthand for glob('**/' + pattern) -- recursive search from the current path downward.
glob('*.py') only matches files directly inside project; rglob('*.py') (or glob('**/*.py')) searches every subdirectory recursively.
project = Path('src')
for entry in project.iterdir():
print(entry) # every direct child, files and directories
py_files = list(project.glob('*.py')) # direct children only
all_py_files = list(project.rglob('*.py')) # every .py file, any depthRenaming, Moving, and Deleting
rename(target) moves/renames a path to target (which may overwrite an existing file on some platforms and not others -- replace(target) is the portable choice that always overwrites). unlink(missing_ok=False) deletes a file (missing_ok=True suppresses the error if it's already gone). Removing a non-empty directory tree requires shutil.rmtree (from the standard shutil module, not pathlib itself).
draft = Path('draft.txt')
draft.write_text('work in progress', encoding='utf-8')
final = draft.replace('final.txt') # portable rename-and-overwrite
print(final.exists(), draft.exists()) # True False -- draft.txt no longer exists
final.unlink() # delete the filePlatform-Aware Path Handling
Path is actually a factory: instantiating Path(...) returns a PosixPath on Linux/macOS or a WindowsPath on Windows automatically, each handling that platform's separator ('/' vs '\\') and conventions. PurePosixPath and PureWindowsPath are available explicitly when you need to manipulate a path for a different platform than the one you're running on (e.g. building a Windows-style path while running on Linux) without touching the actual filesystem.
The 'Pure' variants only manipulate path strings -- they never touch the actual filesystem, so they work identically regardless of what OS is actually running the code.
from pathlib import PureWindowsPath, PurePosixPath
win_path = PureWindowsPath('C:/Users/ana/file.txt')
print(win_path.name) # 'file.txt' -- works even though we're not on Windows
posix_path = PurePosixPath('/etc/config')
print(posix_path.parent) # /etcAbsolute Paths and Resolution
is_absolute() checks whether a path is already absolute. absolute() makes a path absolute relative to the current working directory without resolving symlinks or '..' segments. resolve() goes further: it makes the path absolute and normalizes it, resolving any '..' segments and symbolic links to their real target.
rel = Path('data/../data/report.csv')
print(rel.is_absolute()) # False
print(rel.resolve()) # e.g. /home/user/project/data/report.csv -- normalizedCommon Mistakes
The most frequent pathlib mistakes are mixing pathlib with hand-built string concatenation (str(path) + '/' + name) instead of the / operator, forgetting that glob() is not recursive by default (rglob is), and calling rmdir() on a non-empty directory (it raises -- shutil.rmtree is needed instead).
Complete Example: Organizing Files by Extension
entry.suffix is empty for a file with no extension, so 'or \'no-extension\'' gives that bucket a readable label instead of an empty-string key.
from pathlib import Path
from collections import defaultdict
def group_by_extension(directory: Path) -> dict[str, list[Path]]:
groups: dict[str, list[Path]] = defaultdict(list)
for entry in directory.iterdir():
if entry.is_file():
groups[entry.suffix or 'no-extension'].append(entry)
return dict(groups)
workdir = Path('downloads')
workdir.mkdir(exist_ok=True)
(workdir / 'report.csv').write_text('a,b\n1,2\n', encoding='utf-8')
(workdir / 'photo.jpg').write_bytes(b'\xff\xd8')
print(group_by_extension(workdir)) # {'.csv': [...], '.jpg': [...]}Key Takeaways
Path objects join with the / operator, always producing the correct platform-native separator.
name/stem/suffix/parent/parts expose a path's pieces as properties, without manual string slicing.
read_text/write_text and read_bytes/write_bytes handle a whole file in one call; Path.open() gives a streaming file object for larger files.
glob() matches within one directory level by default; rglob() (or a '**' pattern) searches recursively.
Path automatically becomes PosixPath or WindowsPath depending on the running platform; PurePosixPath/PureWindowsPath manipulate paths for a specific platform without touching the real filesystem.
resolve() normalizes a path to absolute form and resolves '..' segments and symlinks; absolute() only makes it absolute, without that normalization.
full = str(base_dir) + '/' + filename # manual string concatenation, platform-fragile full = base_dir / filename # Path's / operator, correct separator on every platform
Manual string concatenation hardcodes '/' and breaks (or produces mixed separators) on Windows; Path's / operator always inserts the correct platform-native separator.
py_files = list(project_dir.glob('*.py')) # expecting this to search subdirectories too py_files = list(project_dir.rglob('*.py')) # recursive search glob() by default only matches direct children of the directory it's called on; rglob() (or glob('**/*.py')) is needed to search every nested subdirectory as well.
some_dir.rmdir() # OSError if some_dir still contains files import shutil shutil.rmtree(some_dir) # removes a directory and everything inside it
Path.rmdir() only removes an empty directory and raises OSError otherwise; deleting a non-empty directory tree requires shutil.rmtree from the standard shutil module.
Why is the / operator overloaded on Path objects, and what problem does it solve compared to manual string concatenation?
Path.__truediv__ is repurposed for path joining so that base / 'file.txt' inserts the correct platform-native separator automatically; manual string concatenation like base_str + '/' + 'file.txt' hardcodes '/' and can produce incorrect or mixed separators on platforms like Windows.
Given Path('archive.tar.gz'), what are its .stem and .suffix, and why might that surprise someone expecting the 'full' extension to be stripped?
suffix is '.gz' and stem is 'archive.tar' -- pathlib only ever splits off the single final extension, so multi-part extensions like '.tar.gz' are not fully stripped by stem; .suffixes gives ['.tar', '.gz'] if you need every extension segment.
What is the difference between glob('*.txt') and rglob('*.txt') on a Path representing a directory?
glob('*.txt') matches only direct children of that directory; rglob('*.txt') searches recursively through every nested subdirectory as well, equivalent to glob('**/*.txt').
Why does mkdir(parents=True, exist_ok=True) avoid two different errors that a plain mkdir() call could raise?
parents=True creates any missing intermediate directories instead of raising FileNotFoundError when a parent doesn't exist yet, and exist_ok=True suppresses the FileExistsError that would otherwise be raised if the target directory is already there.
What does Path.resolve() do that Path.absolute() does not?
absolute() only prefixes the current working directory to make a relative path absolute, without touching its structure; resolve() goes further, normalizing '..' segments and following symbolic links to their real target, always producing a fully normalized absolute path.
Why does Path('folder/file.txt') instantiate as a WindowsPath on Windows and a PosixPath on Linux/macOS, and why does that matter?
Path is a factory class that dispatches to the concrete subclass matching the running platform, so path-separator and formatting differences are handled automatically; this is why plain Path objects from different platforms can't always be mixed or compared meaningfully, and why PurePosixPath/PureWindowsPath exist for manipulating a specific platform's paths explicitly regardless of where the code runs.
Write a function that takes a directory Path and returns the total size in bytes of every file directly inside it (not recursive).
from pathlib import Path def total_size(directory: Path) -> int: return sum(entry.stat().st_size for entry in directory.iterdir() if entry.is_file()) d = Path('assets') d.mkdir(exist_ok=True) (d / 'a.txt').write_text('hello', encoding='utf-8') (d / 'b.txt').write_text('hi', encoding='utf-8') print(total_size(d)) iterdir() lazily lists direct children; the generator expression filters to files only (is_file()) and sums each one's size from entry.stat().st_size, giving the combined byte size without recursing into subdirectories.
What does this print?
reports/2026 summary.final.csv summary.final .csv -- parent is the containing directory as a Path, name is the full final component, stem strips only the last suffix ('.csv'), leaving 'summary.final' intact.
Write a function that finds all '.log' files anywhere under a given directory (including subdirectories) and returns their paths sorted alphabetically.
from pathlib import Path def find_log_files(root: Path) -> list[Path]: return sorted(root.rglob('*.log')) root = Path('logs') (root / 'sub').mkdir(parents=True, exist_ok=True) (root / 'app.log').write_text('...', encoding='utf-8') (root / 'sub' / 'debug.log').write_text('...', encoding='utf-8') print(find_log_files(root)) rglob('*.log') searches recursively through every subdirectory under root for files matching the pattern, returning Path objects that sorted() can order alphabetically since Path supports comparison operators.
from pathlib import Path
p = Path('reports/2026/summary.final.csv')
print(p.parent, p.name, p.stem, p.suffix)