AlgoMaster Logo

pathlib Module

Medium Priority26 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

Working with file paths as plain strings gets messy fast: you concatenate with the wrong separator, you forget to strip a trailing slash, you write code that works on macOS and breaks on Windows. The pathlib module fixes this by giving you a Path object that knows how to join, split, inspect, and act on paths in a cross-platform way. This is the modern path API and the one you should reach for first.

Why pathlib Exists

The classic way to build a path looks innocent until you actually try to use it across platforms.

That works on macOS and Linux. On Windows, the path separator is \, and the same code produces something the operating system might still accept, but mixing styles is fragile. The os.path.join function fixes the separator issue, but you still get a string back, which means everything you want to do next, including checking whether the path exists, reading its contents, or pulling out the file extension, lives in a different module and a different function.

pathlib, added in Python 3.4, replaces that scattered toolkit with one type. A Path is an object that represents a filesystem path; it has methods for inspection, methods for I/O, and an overloaded / operator for joining. Everything you used to call os.path.something(path_string) to do, you now call as path.something().

The output type is PosixPath on macOS and Linux and WindowsPath on Windows. You don't pick the concrete subclass yourself; Path() returns the right one for the OS you're running on. The string form of the path uses the platform's native separator, so the same code prints exports\C-4821\orders.csv on Windows without any conditional logic.

Constructing Paths

Path() accepts a string, multiple strings, another Path, or any object that implements __fspath__. When you pass multiple arguments, it joins them using the platform separator.

All three constructions produce equal paths. The multi-argument form is convenient when the parts are already separate variables; the slash operator (covered in the next section) is the most readable form when you want to compose a path inline.

Two helpers give you well-known anchor points without hardcoding strings:

Path.cwd() returns the current working directory the program was launched from. Path.home() returns the current user's home directory. Both return Path objects, so you can immediately compose further with /.

Joining Paths with the / Operator

pathlib overloads / so you can join paths the same way you'd write them on a command line.

The left operand must be a Path. The right operand can be a Path or a string. The result is always a new Path. The operator handles separators correctly even when components have leading or trailing slashes:

The first case strips the trailing slash. The second case is more interesting: when the right operand is absolute (starts with /), it replaces the left operand entirely. This matches how os.path.join and shell path-joining work, but it does surprise people the first time they hit it. Treat absolute-looking strings carefully when you're building paths from user input or config.

What's wrong with this code?

Path objects don't support +. The whole point of the type is to push you away from string concatenation. The fix is to use /:

The slash operator is shorter, cross-platform, and harder to get wrong. If you find yourself reaching for + or f-strings to glue path parts together, you're doing it the old way.

Anatomy of a Path

A path has parts: a parent directory, a file name, a stem (name without extension), an extension. pathlib exposes each one as a property. The diagram below labels each property for the path exports/2024-Q4/customer-C4821/orders.csv:

The properties are read-only views into the path; they don't touch the disk. Here's each one in code:

A few things worth pointing out. name is just the last component as a string; parent is a Path, not a string, so you can keep chaining methods. parts is a tuple of every component as strings, which is handy when you want to slice or filter by position. anchor is the root portion, which is empty for a relative path and something like / on macOS or C:\ on Windows for an absolute path.

suffixes (plural) returns every extension when a file has more than one, which is common with compressed exports:

suffix only returns the last extension, which is usually what you want. suffixes gives you the full chain. stem strips just the last extension off the name, not all of them, so orders-2024-Q4.csv.gz has the stem orders-2024-Q4.csv. If you need the bare name without any suffix, strip them one at a time or use a manual split.

The parents property (plural) is a sequence of every ancestor directory, walked outward from the current path:

This is useful when you want to find the nearest containing directory that satisfies some condition, like searching upward for a config file.

Checking What's on Disk

So far the path operations have been purely textual; no filesystem access happens until you ask. A handful of predicate methods tell you whether the path corresponds to something real and what kind of something it is.

exists() returns True for anything: file, directory, symlink, FIFO, anything else the OS exposes as a path. is_file() and is_dir() are narrower; they each return True only for that specific kind. All three follow symlinks by default, so a symlink pointing to a file makes is_file() return True. To check the symlink itself rather than its target, use is_symlink():

The is_* predicates return False (not an error) when the path doesn't exist. That makes them safe to call before doing anything destructive.

Absolute, Resolved, and Relative Paths

Paths come in two flavors: absolute (starting from the filesystem root) and relative (interpreted from the current working directory). pathlib gives you methods to move between them.

absolute() returns an absolute path without touching the disk; it just prepends the current working directory. resolve() goes further: it makes the path absolute, follows symbolic links, and collapses .. and . segments. The result is the canonical location on disk. The difference shows up when the path contains a ..:

absolute() leaves the .. segment in place; resolve() simplifies it. Prefer resolve() when you want to compare paths for equality, because two textually different paths can point to the same file.

relative_to(other) is the inverse direction: given two paths, compute the relative path from one to the other.

This is handy when you're building a list of files relative to a project root, for example when generating a manifest or building a list of upload keys. If product_path isn't actually inside base, relative_to raises ValueError; it's not a "best effort" function.

Reading and Writing Through Path

Paths can do the file I/O themselves. For small files where you want everything in memory at once, read_text and write_text replace the open and read/write ceremony.

write_text opens the file in text-write mode, writes the string, and closes it. It returns the number of characters written. read_text reads the entire file as a string. Both accept an optional encoding argument (default utf-8 on most platforms, but it follows the locale; pass encoding="utf-8" explicitly when portability matters).

For binary data (product images, PDFs, downloaded archives), use read_bytes and write_bytes:

These are convenience wrappers. They open, transfer all the data, and close in one call. For files that don't fit in memory, or when you need to read line by line, fall back to path.open(), which behaves identically to the built-in open:

path.open(mode, encoding=...) returns the same file object you'd get from the built-in open(path, ...). The advantage is that the path object is the entry point; you don't have to convert it to a string first.

A few other in-place operations:

touch() creates an empty file (or updates the modification time of an existing one). rename() moves a file to a new path within the same filesystem and returns the new Path. unlink() deletes a file (use missing_ok=True if you don't want an error when the file is already gone).

Creating and Removing Directories

mkdir() creates a directory at the path. Two keyword arguments matter:

ArgumentDefaultEffect
parentsFalseCreate missing parent directories along the way
exist_okFalseDon't raise FileExistsError when the directory already exists

You almost always want parents=True, exist_ok=True:

Without parents=True, the call fails if exports/2024-Q4 doesn't already exist. Without exist_ok=True, the call fails if the leaf directory already exists, which makes the operation non-idempotent and forces you to wrap it in try/except. The combined form is the idiomatic "make sure this directory exists" line.

To remove an empty directory, use rmdir(). To remove a file, use unlink(). Removing a directory tree (a directory with files inside it) needs shutil.rmtree.

Iterating a Directory

iterdir() yields the entries (files and subdirectories) inside a directory, one Path per item. It does not recurse.

The order isn't guaranteed; it depends on the filesystem. If you need a stable order, sort the result yourself.

iterdir() returns an iterator, not a list. For huge directories that's a feature: you can start processing the first entries while the rest of the listing is still being walked. If you want a list, wrap it: list(uploads.iterdir()).

To filter, combine with a comprehension or with the glob method below.

Globbing: Pattern-Based File Listings

glob(pattern) matches the pattern against the directory's contents and yields matching paths. It supports the usual shell wildcards: * (anything except a separator), ? (one character), and [abc] (character class).

glob only looks at the immediate directory; it doesn't descend into subdirectories. To recurse, use rglob (recursive glob) or use the ** pattern:

rglob("*.png") is equivalent to glob("**/*.png"). The ** segment means "this directory and all its subdirectories", recursively. Use whichever reads better to you; the behavior is the same.

You can combine globs with predicates for finer filters:

The pattern narrows the candidates by name; the is_file() check rejects directories that happen to match the pattern. For most product use cases, that combination handles "find me all the files of type X under directory Y".

match(pattern) checks a single path against a glob pattern without listing the filesystem:

match is right-anchored: "*.csv" matches any path whose last component ends in .csv. To anchor at the start, include the full structure ("exports/**/*.csv").

Modifying Path Components

Sometimes you have a path and want a sibling: same directory, slightly different name or extension. with_name, with_stem, and with_suffix give you these.

with_name replaces the entire last component (name plus extension). with_stem replaces just the part before the last extension. with_suffix replaces the last extension; passing "" strips it entirely. None of these methods change the original path; each returns a new Path.

A common pattern is "write the result next to the input, with a different extension":

with_stem was added in Python 3.9. If you're targeting older interpreters, build the equivalent with with_name(f"{p.stem}-final{p.suffix}").

PurePath vs Path

Path is the type you've been using. Internally it has a sibling called PurePath (and concrete PurePosixPath and PureWindowsPath variants). The split matters when you want to manipulate path strings without ever touching the filesystem.

TypeWhat it does
PathFull path object with I/O methods (read_text, mkdir, etc.) and inspection methods
PurePathPath manipulation only; no methods that touch the disk

You use PurePath when:

  • You're parsing or building paths for a different operating system than the one running the code. For example, generating Windows paths from a Linux server.
  • You're writing tests that should work without creating real files.
  • You want to make it explicit at the type level that a function never reads or writes the disk.

PureWindowsPath displays with backslashes even when you're running on macOS or Linux. PurePosixPath always uses forward slashes. Neither type has methods like exists() or read_text(), because both might run on a machine that doesn't actually have a C:\Users\maya directory.

For day-to-day code, stick with Path. Reach for PurePath only when you specifically need cross-platform path generation without filesystem access.

A Quick Mental Model: Path Methods by Category

The full Path interface is large, but it groups cleanly. Keep this map in your head:

CategoryExamplesNotes
ConstructionPath(), Path.cwd(), Path.home(), / operatorNo disk access
Inspection (textual)name, stem, suffix, parts, parent, parents, anchorNo disk access
Inspection (filesystem)exists(), is_file(), is_dir(), is_symlink(), stat()One system call each
Resolutionabsolute(), resolve(), relative_to()Only resolve touches the disk
Modification (textual)with_name(), with_stem(), with_suffix()Returns a new Path
I/Oread_text(), write_text(), read_bytes(), write_bytes(), open(), touch()Reads or writes data
Directory opsmkdir(), rmdir(), unlink(), rename(), iterdir(), glob(), rglob()Filesystem changes or listings
Pattern matchingmatch()Purely textual

When you're picking a method, ask first whether the operation needs disk access. If it doesn't (renaming a path string, checking the extension), prefer the textual operations; they're cheap and they don't fail if the file isn't there yet.

Putting It Together

A realistic snippet that uses most of what we've covered: scan an upload directory, sort images by extension into per-format subfolders, and write a manifest.

Every operation here is a Path method or property. There's no os.path, no string concatenation, no manual separator handling. The code reads top to bottom as a description of what's happening: list the uploads, filter to files, derive a target directory from the extension, make sure it exists, move the file, record the new location, write a manifest.

Quiz

pathlib Module Quiz

10 quizzes