Unified Filesystem Operations with Python's Pathlib Module

Filesystem operations in Python can feel clumsy, because the relevant operations are distributed over so many different modules in the standard library: some are in os (itself very large and bloated), some in os.path, then there is shutil, and you may also need either glob or fnmatch. Why does this need to be so complicated?

Turns out, it isn’t. Python’s pathlib module provides unified access to a complete set of filesystem objects and operations. Unfortunately, the pathlib module itself can appear a bit daunting. But in reality, it’s really very simple.

After finishing this post, I became aware that of a recent presentation about pathlib at PyCon US 2026.

Introduction

The pathlib module achieves its convenience and unified appearance by combining three technically separate, but intimately related entities:

  • file system paths
  • file system objects (such as files and directories) and their operations
  • the file system itself

In some sense, this may seem to violate the principles of good modeling, by creating a “leaky abstraction”. However, in the present case, the relation between these entities is so close that an attempt to keep them separate creates artificial boundaries in the most natural application areas. After all, what good is a file system path, if not as a handle to the actual file system object that it points to? The pathlib module works precisely because it unifies what naturally belongs together and therefore typically is used together.

The reference documentation emphasizes that pathlib is “object-oriented”, which is true, but a bit misleading, because the specifically “object-oriented” features of this libary (in particular, the somewhat intricate inheritance hierarchy) do not usually manifest in practical work. I find it more useful to think of the Path abstraction as a unified handle for the complete set of path and filesystem operations: a Path instance represents a path to a file or directory, and everything that you can do with it. Simple.

That being said, the encapsulation provided by the Path class is good enough that these handles can, for instance, be stored in data structures, or even be used as keys in dictionaries. (At heart, they are just strings, after all!).

One word of warning: Check your Python version before relying too much of individual features of pathlib. The library continues to evolve, and many useful features have been added only quite recently (or not at all yet!).

Platform Dependence, and Pure Paths vs. IO Operations

Two observations explain the somewhat complex inheritance hierarchy in the pathlib module.

The first such observation is that operations on file paths alone do not involve IO and therefore do not touch the filesystem at all. In other words, we can create filenames, add extensions, prefix directory paths, all without ever accessing the disk.

The pathlib module captures this abstraction in the form of a PurePath class: (essentially) just string operations that maintain some additional invariants. Only when we are trying to access the object that is described by such a PurePath (typically to read or write it) do we touch the disk. Objects that implement these additional IO operations are referred to as “concrete paths”.

The second observation is that pure paths are platform-dependent (whereas IO operations, by and large, are not).

It is these two observations that drive the class hierarchy in the pathlib module: pure paths to model file path operations, and concrete paths to implement IO, both in a Windows and a Posix flavor, for portability.

That being said, all this complexity is effectively hidden from the working programmer (almost all of the time), because the library ensures that the pathlib.Path class is always of a type appropriate for the current platform and providing all supported functionality. In other words, simply instantiate an object of the pathlib.Path class and stuff “just works”. (The exceptions arise when attempting to create Windows paths on a Posix platform, or vice versa, and similar uncommon and special edge cases.)

This also means that generally only a single class needs to be imported, like so:

from pathlib import Path        # The usual way to import the module

That’s all that’s needed to get all of the module’s functionality, almost all of the time.

The pathlib.Path class

The central abstraction in the pathlib module is the Path class. This class combines standard operations on path names with access to the corresponding filesystem objects. As a convenience, it also provides some methods for instantiating further Path objects, either representing a specific location in the filesystem, or by walking the filesystem tree.

It will be useful to collect some of its members and operations, grouped semantically. This list is representative of the most useful operations, but neither exhaustive nor complete — see the reference documentation for that.

Keep in mind that operations on paths do not touch the actual filesystem (or disk) — in fact, the object described by a path may not even exist!

The filesystem is only accessed once information about an object described by a Path instance is queried (e.g. via stat(), or if a file or directory is created, read, written, changed, or deleted.

Creation and Conversions
Path()                     # Constructor: current directory
Path( *segments )          # Constructor (see below)

Path.cwd()                 # Class (factory) method
Path.home()                # Class (factory) method
Path.from_uri()            # Class (factory) method, parses a "file://" URI

p = Path(...)
str(p)                     # string representation of the path
p.as_uri()                 # returns a "file://" URI representation as string

The constructor takes an arbitrary number of path “segments” and joins them (according to the current platform’s rules). Path segments may either be strings or other “path-like” objects.

One special rule: If a segment represents an absolute path, all preceding segments are ignored.

The following examples are adapted from the Python reference documentation:

Path( "parent", "some/path", "file.txt" )     # parent/some/path/file.txt
Path( Path( "directory" ), Path( "file" )     # directory/file

Path( "/etc", "/usr", "lib64" )               # /usr/lib64
Path Components (Data Members)

For any Path instance, its path segments are available through properties (data members). Some of the most important ones include (additional properties may exist on other platforms, for instance to designate a drive):

p = Path(...)              # any Path instance

p.parts    -> tuple        # tuple of segments, split on separator
p.root     -> string       # filesystem's root directory
p.parent   -> string       # the parent directory to the current path
p.parents  -> "sequence"   # sequence of ALL ancestors, sorted inside/out
p.name     -> string       # last segment, file- or directory-name
p.suffix   -> string       # LAST extension, including the dot
p.suffixes -> list         # list of ALL extensions, ordered, with dot
p.stem     -> string       # pathname (incl parents), but w/o last extension

The path components are generally represented as strings (or as collections of strings, as needed).

This collection of properties holds some surprises:

  • While .suffixes is a list, .parents is a “sequence” and only suitable for use in a loop: for p in Path("/usr/lib/python3").parents: print(p) would print “/usr/lib”, “/usr”, and “/” (in that order: the lowest ancestor first, and the root directory last).

  • There is no way to get the actual “stem” of a filename, without any extensions. The .stem property only shaves of the last: Path( "archive.tar.gz" ).stem == "archive.tar".

Redundant path elements (such as doubled slashes: /usr//lib) are normalized. But double-dots (/../) and leading doubled slashes are retained, since they are relevant (for example in the presence of symlinks).

Path Operations

The operator / has been overloaded to concatenate path segments. The segments may either be strings or Path instances:

p = "/usr" / Path( "share" ) / "python3" 

An alternative is the joinpath() function, which takes an arbitrary number of arguments, and appends them to the existing path:

p = Path( "/usr" ).joinpath( "share", "python3" )

Several methods exist to create new path instances, by modifying an existing one. Each of these methods leaves the original Path unchanged, but returns a new, modified instance

p = Path( ... )

p.with_name( string ) -> Path       # Replace the "name"
p.with_stem( string ) -> Path       # Replace the "stem"
p.with_suffix( string ) -> Path     # Replace the "suffix"

These methods use the same definition of stem and suffix as discussed before, in particular “stem” is the “name” without its last extension only, and “suffix” refers to this last extension.

It is possible to “rebase” a path, that is, calculating a new path that is relative to one of its parents, or to check whether such an operation is possible:

p = Path( "/usr/lib/python3" )
p.relative_to( "/usr" )             # == Path("/lib/python3")
p.is_relative_to( "/usr" )          # == True

Two methods exist to normalize a path. They take a path object, treat it as relative to the current directory, and return a Path object representing the equivalent absolute path. The absolute() method does not resolve symlinks encountered in the hierarchy; the resolve() method does.

Filesystem Information

Several methods exist to query properties of the concrete filesystem object described by a Path. Note that these methods do query the actual filesystem. Some of them are:

P = Path( ... )

p.exists()

p.is_file()
p.is_dir()
p.is_symlink()

These methods normally follow symlinks; to prevent this, add the keyword argument follow_symlinks=False (available in recent versions of the library only!).

To obtain OS-level information about a filesystem object, use the stat() or lstat() functions, both of which return an os.stat_result object. (If Path points to a symlink, then lstat() returns info on the symlink, rather than its target.)

Quite generally, care must be taken regarding symlinks, because their semantics are naturally somewhat ambiguous. For instance, by default, the exists() method, when invoked on a Path object representing a broken symlink, will report False, indicating that the target of the link does not exist, although the filesystem object represented by the Path in question is, in fact, there! (I stepped into this trap already!)

Reading and Writing Files

Several methods exist to support regular IO. The most important is open(), which works just like the built-in open() function. It returns a regular file object.

In addition, Path provides methods read_text() and read_bytes() that return the entire file contents as a string or byte buffer respectively, as well as write_text(string) and write_bytes([]byte) to write to files.

Wildcards, Globbing, and Directory Listings

The pathlib library supports the “globbing” of wildcards, similar to the pattern language provided by the Unix shell. It is used both for approximate comparisons between paths, and to filter the entries in directory listings.

Approximate matching of paths can be done with the match( pattern ) method, for instance:

Path( "/usr/lib/python3" ).match( "*/lib/* )    # True
Path( "/usr/lib/python3" ).match( "*/lib )      # False

There is also (as of Python 3.13) a full_match( pattern ) method, which supports the general ** operator and recursive matching across subdirectories.

There are several ways to examine the entries in a directory. They all return generators and are therefore intended to be used in a looping construct. Entries are returned in arbitrary order. Globbing can also be used to filter the result set.

.iterdir()        # iterate over all entries in directory
.glob(pattern)    # iterate over all entries matching the pattern
.rglob(pattern)   # like glob(), but include subdirectories recursively
.walk()           # recursively, return tuple (path, dirs, files),
                  # with the current path, a list of directory entries,
				  # and a list of filenames. Similar to os.walk().

The pattern language for globbing includes:

**        # match any number of path segments, including zero.
*         # match an entire path segment, or any number of non-separator chars
?         # match single non-separator character
[seq]     # match single character from given sequence
[!seg]    # match single character not in given sequence
Filesystem Operations

The Path object also provides methods for all the usual filesystem operations, such as:

mkdir(), rmdir()
rename(), replace()
copy(), copy_into(), move(), move_into()
symlink_to()
unlink()

The difference between rename() and replace() is that replace() will unconditionally clobber the target, if it exists, be it file or directory, whereas rename() may raise an error.

The difference between copy() and copy_into() (and move() and move_into()) is that the former versions create the target directory if it does not exist, whereas the latter versions raise an exception in that case. (All of these methods were added only in Python3.14.)