Representing Python paths using pathlib
For humans, by humansEvery article on LWN.net is written for humans, by humans. If you've enjoyed this article and want to see more like it, your subscription goes a long way to keeping the slop at bay. We are offering a free one-month trial subscription (no credit card required) to get you started.
At the outset of his PyCon US 2026 talk, Trey Hunner said that his goal was for attendees to stop representing filesystem paths as strings and to use pathlib instead. That's kind of a tall order, at least for longtime Python users, since string-based paths have been pervasive—and mostly work. It is that "mostly" part that makes Hunner want to see things change, of course, so he set out to describe a lesser-known corner of the language and to try to change some minds.
Hunner introduced himself as a Python trainer for development
teams; he also runs Python
Morsels, which is a "weekly skill-building service
" for
developers of all skill levels. Beyond that, he publishes a newsletter with a
weekly Python tip.
Strings and paths
For most of Python's history, paths have been represented as regular strings, which works and will continue working forever, but there are problems with doing so. Over ten years ago, Python 3.4 introduced pathlib based on PEP 428 ("The pathlib module – object-oriented filesystem paths") in order to clean things up. There are multiple problems he would be describing for string paths: the standard-library alternatives to pathlib are difficult to find and awkward to use, while string paths can lead to bugs and distinguishing paths from other strings can be hard to do.
He turned the clock back to 2016, when many users were still on Python 2.7,
to look at what existed in the standard library to work with file paths. There
were four modules, os, os.path,
glob,
and shutil,
"that we might have reached for
" to deal with paths.
shutil has "a bunch of high-level file-related stuff, including
seven different functions for copying files and directories
". By
comparison, glob is simpler, with just glob() and
iglob() for doing shell-like pattern matching for path names.
os.path is used for "manipulating file paths
", including
splitting, joining, and querying them.
The story with the os module is more complicated, he said. In his opinion, Python has two "junk drawers" in its standard library: os and sys. os is for things related to the computer and operating system, while sys contains "junk" related to the Python interpreter itself. os has lots of functions for working with paths and files, as well as many that are for other purposes. He briefly listed off more than a dozen file- or path-related functions, but then showed seven slides listing all of the other functions in os—probably around 200 in all (slides, use arrow keys to advance). His point was that it was not easy to find path functions in os, not to mention the others scattered in os.path, glob, and shutil.
But, if paths are just strings, he asked, why can't the string-manipulation functions be used instead of those in os.path? He showed three versions of some code for a Django project that set a variable to the path of the file directory's sibling templates directory. The first:
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TEMPLATES_DIR = os.path.join(BASE_DIR, "templates")
and the second:
BASE_DIR = os.path.abspath(__file__).rsplit("/", maxsplit=2)[0]
TEMPLATES_DIR = BASE_DIR + "/templates"
Both need to have imported os.path, naturally. The second works,
but is somewhat less readable perhaps; it also suffers from a portability
problem, in that Windows uses "\" as its path separator, so it may
not work on that kind of path.
Importing os allows using os.sep, but even that is not
all that readable, he said:
BASE_DIR = os.path.abspath(__file__).rsplit(os.sep, maxsplit=2)[0]
TEMPLATES_DIR = BASE_DIR + os.sep + "templates"
The readability and maintainability of the latter two examples is why the
utility functions in
os.path were added, he said. Meanwhile, depending on where various
parts of the path came from, there may be a mixture of forward and backward
slashes in it, which actually works on Windows, but looks pretty strange,
he said. (It does not, however, work at all on Linux.)
There is another problem with paths as strings, which is something Hunner calls "stringly typed code". Data that is passed as strings, when a better type exists is stringly typed. He gave an example (which apparently was from last year):
target = "2025-09-25"
if target[:4] == "2025":
print("That's this year")
That works, but it assumes that the string is actually a date in the proper
format.
Using a datetime
object instead leads to a little more code but provides a guarantee that
the string is actually a valid date; otherwise strptime()
would raise ValueError:
from datetime import datetime
user_input = "2025-09-25"
target = datetime.strptime(user_input, "%Y-%m-%d").date()
if target.year == 2025:
print("That's this year")
Hunner argued that using strings to represent paths is stringly typed as well. Returning to his earlier example, he showed how a Path object can change that:
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
TEMPLATES_DIR = BASE_DIR / "templates"
As Hunner described later in the talk, the resolve()
method turns a Path into an absolute path and the parent
attribute resolves to the path of the parent directory. The slash
("/") operator is used to join two paths.
For code that uses type annotations, a Path object provides another benefit: a type checker can spot problems where strings and paths are being confused in the code. Being able to distinguish those uses makes it easier to reason about the code and to catch bugs in it as well.
Meanwhile, the Path version is easier to read than the os.path version he showed earlier:
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TEMPLATES_DIR = os.path.join(BASE_DIR, "templates")
In order to understand that first line, he has to read it from right to
left, getting the absolute path of the file using abspath(),
its parent directory (dirname()),
and the parent of that. It uses nested calls, while the Path
version chains calls and attributes in a way that reads much more
naturally, at least to him.
He showed some examples where Path alternatives are easier to read and use, including replacing the classic with block for reading a file with a one-liner:
with open("config.txt", mode="rt") as file:
content = file.read()
# one line replacement using Path
content = Path("config.txt").read_text()
Making a directory using the os module means either using mkdir()
or makedirs()
depending on whether the intermediate directories should be created. For a
Path object, the mkdir()
method has a parents flag that can be used to create intermediate
directories. Likewise, for copying files, the Path object has
the copy()
and copy_into()
methods that were added in Python 3.14; prior to that, four separate
functions from shutil could have been used (copyfile(),
copy(),
copy2(),
or copytree()). "I can't tell which of these you need because I don't remember which one does which."
Using pathlib
The pathlib module contains one main thing, Hunner said: the Path object. The Path() constructor takes a string representation of a path and returns a Path object, which will either be a PosixPath or a WindowsPath depending on the underlying operating system. Those two types support all of the same operations and just differ in the way they normalize paths; users should not have to care.
There are a little over two dozen useful methods that can be called on Path objects, six attributes (e.g. name), and two class methods (Path.cwd() for the current working directory and Path.home() for a user's home directory). There are more than 70 attributes and methods, overall, but he only rarely uses the others.
When pathlib was first introduced, users often needed to convert
Path objects to strings in order to use them
(e.g. open(str(path), ...), but over time more and more of
the Python builtins and standard-library modules have added support.
"Pretty much every utility in Python that accepts a file path will also
accept a Path object
", he said. He listed six separate
examples and noted that even functions and methods that are explicitly
designed to take path strings will also work with Path objects.
So, legacy code that still uses the older functions
(e.g. os.path.join() or os.mkdir()) will still work
correctly when passed a Path object. Beyond that, most
third-party packages, such as Django, pandas, and pytest, will accept
Path objects. If a third-party package does not accept
Path objects, it is probably a bug that should be reported, Hunner
said.
The way to start using pathlib is to simply start passing the strings that specify paths to the Path() constructor:
>>> from pathlib import Path
>>> notes_path = Path("Documents/notes.txt")
For portability, the forward slash should be used to separate names in
string literals, but a WindowsPath will normalize any
backslash-separated names as needed.
A Path object can be used to do various things, such as joining it to a file name. That can be done with the joinpath() method or by using the slash operator:
>>> from pathlib import Path
>>> home = Path.home()
>>> path1 = home.joinpath(".config.toml")
>>> path2 = home / ".config.toml"
The slash operator looked odd to him when he first saw it, but it has grown
on him and he now finds it pretty readable. In addition, the constructor
accepts multiple arguments (either strings or other Path objects)
and joins them together, so:
>>> path3 = Path(home, ".config.toml")
>>> path3
PosixPath('/home/trey/.config.toml')
>>> path1 == path2 == path3
True
All three mechanisms are equivalent and none is clearly better than the
others, but he said that the third approach is typically only used when it
is not known whether the components are strings or Path objects.
Hunner said that he finds the os.path functions to be
"unfortunately named
", with squished-together, often shortened
words, while the equivalents in pathlib are better thought-out.
For example, he prefers path.name over using
os.path.basename(path) and the same goes for path.parent
over os.path.dirname(path). Even more compelling is using
path.suffix over os.path.splitext(path)[1]. He noted
that there are cheat sheets for pathlib in an article that he
wrote and in
the Python
pathlib documentation.
Inheriting and extending
Since Python 3.12, classes can extend the functionality of pathlib.Path via inheritance. So, for example, a custom Path class could be created that has a method to change directories:
import os
import pathlib
class BetterPath(pathlib.Path):
def chdir(self):
os.chdir(self)
Inheriting from Path is not likely to be common, but when it is done,
any attributes used by the new class, perhaps for storing metadata, can be propagated to derivative
Path objects (e.g. parent) by overriding the with_segments()
method in the subclass.
Back in 2016, PEP 519 ("Adding a file system path protocol") was accepted for inclusion into Python 3.6. It described what was needed for an object to be considered "path-like", so it would be accepted anywhere a path string or Path object was expected. Effectively, it explained what kind of quack was needed in order for duck typing to determine that the duck should be treated as a path. It turns out that a class only needs to implement a single method, __fspath__(), which returns a string or bytes representation of the path-like object, in order to quack correctly.
Path-like objects that follow the path protocol will be accepted by
open() and other builtins and standard-library functions. In
addition, PEP 519 enables third-party path libraries. He thinks that
pathlib is great, "but if you don't like it, there's a whole
ecosystem of path-like objects beyond just pathlib
". He
showed a "very silly example
" of a path-like class, which did not
inherit from Path, that worked with open() and would work
anywhere else where a Path can be passed.
Common mistakes
Next he described some of the mistakes that new Path users make.
The first is something of a historical accident. When the feature was
introduced in Python 3.4, the usual way to open a file using a path was the
open()
method for Path. When Python 3.6 was released with PEP 519,
there was no need to use that method because the open() builtin started
accepting path-like objects. Since it is simply historical at this point, he thinks that the open() method for
Path should be avoided; "not everyone agrees with that advice,
but I'm on the stage so I get to say this
", Hunner said to laughter.
Another common mistake is to convert Path objects to strings unnecessarily. For displaying or logging a path, using an f-string implicitly converts the object to a string:
>>> print(f"Reading: {path}")
Reading: example.txt
There is no need to do an explicit string conversion of the object for
open() or any of the other functions that accept path-like
objects. "If you think that you need to convert a path to a string, you probably don't."
Lastly, the flexibility of the Path() constructor means that multiple strings can be joined into a single path by passing them to the constructor. There is no need to create a Path object for one piece and use joinpath() or the slash operator to construct the final path.
There is a persistent, but he thinks mostly
unwarranted, complaint: pathlib is slow. "It is true that
pathlib can be slower for some operations, but readability is
sometimes worth a small performance penalty.
" He compared the
performance of os.walk()
with Path.walk()
on 400,000 files. The two were roughly the same at just under one second
(0.91s for os.walk() versus 0.85s), but he noted that
Path.walk() returns strings rather than Path objects.
Converting those strings to Path objects made the test take 2.22s.
Most path operations are not done in tight loops, so the penalty of that
may not really matter much, he said. Path objects provide more
readability, which is also important. "Optimize only when it really matters.
"
He concluded by noting that pathlib is not simply a different API,
it is, instead, an acknowledgment that file paths are "important enough
to warrant their own data type
". It is meant to make handling path
operations easy by representing them properly in the language.
[I would like to thank the Linux Foundation, LWN's travel sponsor, for its
assistance with my trip to Long Beach, CA for PyCon US.]
| Index entries for this article | |
|---|---|
| Conference | PyCon/2026 |
| Python | Path handling |