|
|
Log in / Subscribe / Register

Representing Python paths using pathlib

For humans, by humans

Every 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.

By Jake Edge
August 19, 2026

PyCon US

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

[Trey Hunner]

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
ConferencePyCon/2026
PythonPath handling


to post comments

computational complexity of pathlib

Posted Aug 19, 2026 16:01 UTC (Wed) by pbonzini (subscriber, #60935) [Link] (3 responses)

In one case I found, "x in y.parents" has complexity of O(n^2) where n is the number of parents: n for the construction of n paths, n because each path has length up to n.

Instead the function os.path.commonpath does the expected O(n) work, but 1) it does much more work than just returning a boolean so it could be sped up by a further factor of ~2, and 2) it raises an exception on Windows if the two arguments are on different drives instead of returning False.

Getting the best of both worlds requires a manual implementation; this by the way is not hypothetical, as it was measured on Meson.

computational complexity of pathlib

Posted Aug 19, 2026 17:06 UTC (Wed) by iabervon (subscriber, #722) [Link] (1 responses)

I think "y.is_relative_to(x)" does what you want and should be efficient and maybe more intuitive.

computational complexity of pathlib

Posted Aug 19, 2026 19:39 UTC (Wed) by pbonzini (subscriber, #60935) [Link]

Should be efficient, but it is implemented as "other == self or other in self.parents" so it is not.

computational complexity of pathlib

Posted Aug 19, 2026 18:54 UTC (Wed) by NYKevin (subscriber, #129325) [Link]

I believe the method you are searching for is Path.is_relative_to(), but unfortunately, it appears[1] to be implemented the "wrong" way.

[1]: https://github.com/python/cpython/blob/3.14/Lib/pathlib/_...

a path is not a file

Posted Aug 20, 2026 15:43 UTC (Thu) by cgwaldman (subscriber, #9061) [Link] (3 responses)

The example:

content = Path("config.txt").read_text()

seems to me to conflate paths with file objects. You don't read from a path, you read from the file the path points to. I understand this is just a shortcut but it is vaguely troubling to me and I would avoid this pattern in my own code.

a path is not a file

Posted Aug 20, 2026 15:55 UTC (Thu) by mb (subscriber, #50428) [Link] (1 responses)

Python uses PurePath for representing a path and doing operations on the path itself. Path derives from that and adds file system operations. Yes, the names may be a bit misleading. But Python did split path ops and file ops into these two classes.

a path is not a file

Posted Aug 21, 2026 15:36 UTC (Fri) by rschroev (subscriber, #4164) [Link]

Path adds file system operations to PurePath, but also file operations. The first are things like stat, glob, copy, unlink, ... . The second are reading and writing the file itself. I don't have a problem with that, but I can see why people find the second a bit troubling.

a path is not a file

Posted Aug 21, 2026 13:22 UTC (Fri) by iabervon (subscriber, #722) [Link]

I think the less remote examples establish a useful pattern. You do stat() a path, except that you're really stat()ing the dentry and inode, so that's path.stat(), and then it makes sense to just extract the text of the file at a path as a similar operation, when you don't want to do multiple separate operations on the same open file.

"junk drawer"

Posted Aug 20, 2026 21:26 UTC (Thu) by marcH (subscriber, #57642) [Link] (3 responses)

> 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.

Awesome! For years I've been missing a good name for these top-level directories found in most git repos: "tools/", "scripts/" and "util/".

=> "Junk Drawer" it is!

Perfect, thank you so much Trey.

PS: if I missed any other popular one, please share.

"junk drawer"

Posted Aug 20, 2026 21:31 UTC (Thu) by dskoll (subscriber, #1630) [Link]

I have a directory under my home directory where I keep miscellaneous stuff. I call it Bilge/.

"junk drawer"

Posted Aug 21, 2026 1:43 UTC (Fri) by intelfx (subscriber, #130118) [Link]

Awesome! For years I've been missing a good name for these top-level directories found in most git repos: "tools/", "scripts/" and "util/".

Most Go codebases that I saw have seemingly converged on a nomenclature of hack/ for all of the above.

"junk drawer"

Posted Aug 21, 2026 8:01 UTC (Fri) by stijn (subscriber, #570) [Link]

I use 'shed'. Tools and junk live in the shed and a slew of other stuff too.

Forward slashes on Windows

Posted Aug 20, 2026 21:44 UTC (Thu) by marcH (subscriber, #57642) [Link]

> 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.)

Many Windows don't realize this but forward slashes work 99% of the time on Windows. One of the extremely few places where they don't work is cmd.exe and .bat files. They work in the file explorer, all APIs, powershell, etc.

Of course everyone should use a library like pathlib and never deal with slashes (or os.sep even) directly. Life is too short to waste time with that 1% or wondering whether r"a\b", "a\\b" and "a\\\\b" are the same paths.

PS: most users don't understand case-insensitivity either - but I digress https://lore.kernel.org/lkml/CAHk-=wjajMJyoTv2KZdpVRoPn0L...

PPS: the official docs have a useful section listing the behaviors that differ between os.path and pathlib: https://docs.python.org/3/library/pathlib.html#comparison.... I mean arbitrary behavior choices that are not consequences of the different designs.

reasons someone might still not be able to use pathlib

Posted Aug 27, 2026 20:13 UTC (Thu) by zwol (guest, #126152) [Link] (1 responses)

My day job is, in general, quite enthusiastic about pathlib.

The #1 reason I still need to use os.path sometimes is that os.path.relpath will walk back up the directory tree in order to reach a sibling directory, whereas Path.relative_to will only do that if you pass walk_up=True, which isn't available prior to 3.12. (I didn't even know that that had been added until just now, when I decided to double-check the latest-stable docs while writing this post; day job's minimum supported Python is going to be 3.11 for quite some time still, so that's the version of the docs I have bookmarked.)

The #2 reason I still need to use os path functions sometimes is that os.scandir gives me DirEntry objects, which are much nicer to work with than Path.walk and its tuples of lists. Internally I have a path-walking module that produces DirEntry-esque objects whose path property holds a Path rather than a string.

reasons someone might still not be able to use pathlib

Posted Aug 27, 2026 21:01 UTC (Thu) by rschroev (subscriber, #4164) [Link]

Regarding #2 reason, do you need to list one directory or a full directory tree? I'm asking because that's a pretty big difference, and os.scandir does the first while Path.walk does the second. Wouldn't Path.iterdir be a better fit in the first case, and Path.rglob in the second one?


Copyright © 2026, Eklektix, Inc.
This article may be redistributed under the terms of the Creative Commons CC BY-SA 4.0 license
Comments and public postings are copyrighted by their creators.
Linux is a registered trademark of Linus Torvalds