pathlib.Path

Why and how to use it

/ @treyhunner

trey.io/news โ€” a weekly Python tip ๐Ÿ’Œ

Truthful Technology
Python Morsels

>>> path = "/home/trey/Documents/some_file.txt"
          

pathlib: added in Python 3.4

Problems with not using pathlib

  • โ˜ Utilities are difficult to find
  • โ˜ Utilities are awkward to use
  • โ˜ String paths are error-prone
  • โ˜ Hard to distinguish paths from other strings

os

os.path

glob

shutil

shutil module

copyfile(src, dst), copyfileobj(fsrc, fdst)

copy(src, dst), copy2(src, dst)

copymode(src, dst), copystat(src, dst)

copytree(src, dst), move(src, dst)

rmtree(path)

chown(path, ...)

glob module

glob(pattern)

iglob(pattern)

escape(pathname)

translate(pathname)

os.path module

normpath(name), abspath(path)

basename(path), dirname(path)

isfile(path), isdir(path)

splitext(path), getsize(path)

relpath(path, parent), join(parent, name)

Python's Junk Drawers

os

sys

๐Ÿ”‘๐Ÿ–‡๏ธโœ๏ธ๐Ÿงท

os.getcwd(), os.chdir()

os.chmod()

os.symlink(), os.link(), os.readlink()

os.stat(), os.lstat()

os.scandir(), os.walk()

os.rename(), os.replace(), os.remove()

os.mkdir(), os.makedirs()

os.abort(), os.access(), os.chown(), os.chroot(), os.close(), os.closerange(), os.confstr(), os.copy_file_range(), os.cpu_count(), os.ctermid(), os.device_encoding(), os.dup(), os.dup2(), os.eventfd(), os.eventfd_read(), os.eventfd_write(), os.execl(), os.execle(), os.execlp(), os.execlpe(), os.execv(), os.execve(), os.execvp(), os.execvpe(), os.fchdir(), os.fchmod(), os.fchown()

os.fdatasync(), os.fdopen(), os.fork(), os.forkpty(), os.fpathconf(), os.fsdecode(), os.fsencode(), os.fspath(), os.fstat(), os.fstatvfs(), os.fsync(), os.ftruncate(), os.fwalk(), os.get_blocking(), os.get_exec_path(), os.get_inheritable(), os.get_terminal_size(), os.getcwdb(), os.getegid(), os.getenv(), os.getenvb(), os.geteuid(), os.getgid(), os.getgrouplist(), os.getgroups(), os.getloadavg(), os.getlogin(), os.getpgid(), os.getpgrp()

os.getpid(), os.getppid(), os.getpriority(), os.getrandom(), os.getresgid(), os.getresuid(), os.getsid(), os.getuid(), os.getxattr(), os.grantpt(), os.initgroups(), os.isatty(), os.kill(), os.killpg(), os.lchown(), os.listdir(), os.listxattr(), os.lockf(), os.login_tty(), os.lseek(), os.major(), os.makedev(), os.memfd_create(), os.minor(), os.mkfifo(), os.mknod(), os.nice(), os.open(), os.openpty(), os.pathconf(), os.pidfd_open()

os.pipe(), os.pipe2(), os.popen(), os.posix_fadvise(), os.posix_fallocate(), os.posix_openpt(), os.posix_spawn(), os.posix_spawnp(), os.pread(), os.preadv(), os.ptsname(), os.putenv(), os.pwrite(), os.pwritev(), os.read(), os.readinto(), os.readv(), os.register_at_fork(), os.remove(), os.removedirs(), os.removexattr(), os.renames(), os.rmdir()

os.sched_get_priority_max(), os.sched_get_priority_min(), os.sched_getaffinity(), os.sched_getparam(), os.sched_getscheduler(), os.sched_rr_get_interval(), os.sched_setaffinity(), os.sched_setparam(), os.sched_setscheduler(), os.sched_yield(), os.sendfile(), os.set_blocking(), os.set_inheritable()

os.setegid(), os.seteuid(), os.setgid(), os.setgroups(), os.setns(), os.setpgid(), os.setpgrp(), os.setpriority(), os.setregid(), os.setresgid(), os.setresuid(), os.setreuid(), os.setsid(), os.setuid(), os.setxattr(), os.spawnl(), os.spawnle(), os.spawnlp(), os.spawnlpe(), os.spawnv(), os.spawnve(), os.spawnvp(), os.spawnvpe(), os.splice(), os.statvfs(), os.strerror(), os.sync(), os.sysconf(), os.system(), os.tcgetpgrp(), os.tcsetpgrp()

os.timerfd_create(), os.timerfd_gettime(), os.timerfd_gettime_ns(), os.timerfd_settime(), os.timerfd_settime_ns(), os.times(), os.truncate(), os.ttyname(), os.umask(), os.uname(), os.unlink(), os.unlockpt(), os.unsetenv(), os.unshare(), os.urandom(), os.utime(), os.wait(), os.wait3(), os.wait4(), os.waitid(), os.waitpid(), os.waitstatus_to_exitcode(), os.write(), os.writev()

os.getcwd(), os.chdir()

os.chmod()

os.symlink(), os.link(), os.readlink()

os.stat(), os.lstat()

os.scandir(), os.walk()

os.rename(), os.replace(), os.remove()

os.mkdir(), os.makedirs()

shutil.copy(src, dst), shutil.copy2(src, dst), shutil.copytree(src, dst), shutil.move(src, dst), shutil.rmtree(path), os.getcwd(), os.chdir(), os.chmod(), os.symlink(), os.link(), os.readlink(), os.stat(), os.scandir(), os.walk(), os.rename(), os.replace(), os.remove() os.mkdir(), os.makedirs(), os.path.abspath(path), os.path.basename(path), os.path.dirname(path), os.path.isfile(path), os.path.isdir(path), os.path.splitext(path), os.path.getsize(path), os.path.relpath(path, parent), os.path.join(parent, name), glob.glob(pattern)

Problems with pathlib alternatives

  • โ˜‘ Utilities are difficult to find
  • โ˜ Utilities are awkward to use
  • โ˜ String paths are error-prone
  • โ˜ Hard to distinguish paths from other strings

os.path versus string operations


import os.path

BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TEMPLATES_DIR = os.path.join(BASE_DIR, "templates")
          

import os.path

BASE_DIR = os.path.abspath(__file__).rsplit("/", maxsplit=2)[0]
TEMPLATES_DIR = BASE_DIR  + "/templates"
          

import os
import os.path

BASE_DIR = os.path.abspath(__file__).rsplit(os.sep, maxsplit=2)[0]
TEMPLATES_DIR = BASE_DIR  + os.sep + "templates"
          

Mixed slashes ๐Ÿ˜ฐ


          C:\Documents\ever\seen/a path/like this.txt
          

os.path module

normpath(name), abspath(path)

basename(path), dirname(path)

isfile(path), isdir(path)

splitext(path), getsize(path)

relpath(path, parent), join(parent, name)

Problems with pathlib alternatives

  • โ˜‘ Utilities are difficult to find
  • โ˜ Utilities are awkward to use
  • โ˜‘ String paths are error-prone
  • โ˜ Hard to distinguish paths from other strings

Stringly Typed Code

passes strings around when a better type exists


target = "2025-09-25"

if target[:4] == "2025":
    print("That's this year")
          

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

The age-old tradition of path strings


import os.path

BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TEMPLATES_DIR = os.path.join(BASE_DIR, "templates")
          

from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
TEMPLATES_DIR = BASE_DIR / "templates"
          

Type annotation confusion


from typing import Optional

# Are these meant to represent filenames or file contents?
question: Optional[str] = None
answer: Optional[str] = None

def find_editorconfig_file() -> str:
    ...  # Does this return a file path or file contents?
          

from pathlib import Path
from typing import Optional

question: Optional[Path] = None
answer: Optional[Path] = None

def find_editorconfig_file() -> Path:
    ...  #
          

Problems with pathlib alternatives

  • โ˜‘ Utilities are difficult to find
  • โ˜ Utilities are awkward to use
  • โ˜‘ String paths are error-prone
  • โ˜‘ Hard to distinguish paths from other strings

String paths versus Path objects


import os.path
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TEMPLATES_DIR = os.path.join(BASE_DIR, "templates")
          

from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
TEMPLATES_DIR = BASE_DIR / "templates"
          

from os.path import isdir, join, isfile
from glob import iglob
# ...
if isdir(path):
    for path in iglob(join(path, "*.py"), recursive=True):
        if isfile(path):
            process_file(path)
else:
    process_file(path)
          

from pathlib import Path
# ...
if path.is_dir():
    for path in path.rglob("*.py"):
        if path.is_file():
            process_file(path)
else:
    results.append(process_file(path))
          

with open("config.txt", mode="rt") as file:
    content = file.read()
          

content = Path("config.txt").read_text()
          
Task The old way The pathlib way
Make dir os.mkdir(path) path.mkdir(parents=True)
with parents os.makedirs(path)
Copy file shutil.copyfile(src, dst)
shutil.copy(src, dst)
shutil.copy2(src, dst)
shutil.copytree(src, dst)
path.copy(dst) (3.14+)
path.copy_into(dst) (3.14+)

Problems with pathlib alternatives

  • โ˜‘ Utilities are difficult to find
  • โ˜‘ Utilities are awkward to use
  • โ˜‘ String paths are error-prone
  • โ˜‘ Hard to distinguish paths from other strings

How does pathlib work?

It's all about Path objects


>>> from pathlib import Path
>>> path = Path(".editorconfig")
>>> path
PosixPath('.editorconfig')

          

p.chmod(), p.copy(), p.copy_into(), p.exists(), p.expanduser(), p.glob(), p.is_dir(), p.is_file(), p.iterdir(), p.mkdir(), p.move(), p.move_into(), p.read_text(), p.rename(), p.replace(), p.resolve(), p.rglob(), p.rmdir(), p.stat(), p.unlink(), p.walk(), p.write_text(), p.joinpath(), p.relative_to(), p.with_name(), p.with_stem(), p.with_suffix(), Path.cwd(), Path.home(), p.name, p.parent, p.parents, p.parts, p.stem, p.suffix

p.absolute(), p.as_uri(), p.is_fifo(), p.group(), p.hardlink_to(), p.is_block_device(), p.is_char_device(), p.is_junction(), p.is_mount(), p.is_socket(), p.is_symlink(), p.lchmod(), p.lstat(), p.open(), p.owner(), p.readlink(), p.read_bytes(), p.samefile(), p.symlink_to(), p.touch(), p.write_bytes(), Path.from_uri(), p.as_posix(), p.is_absolute(), p.is_relative_to(), p.is_reserved(), p.full_match(), p.match(), p.with_segments(), p.anchor, p.drive, p.root, p.suffixes


from pathlib import Path

path = Path("example.txt")
with open(str(path)) as f:
    content = f.read()
          

from pathlib import Path

path = Path("example.txt")
with open(path) as f:
    content = f.read()
          

pathlib works everywhere you need it

os.chdir()

shutil.chown()

sqlite3.connect()

logging.FileHandler()

zipfile.ZipFile()

subprocess.run()

It even works with legacy utilities

os.path.abspath()

os.path.isfile()

os.path.join()

os.remove()

os.mkdir()

shutil.copy()

shutil.move()

shutil.copy(src, dst), shutil.copy2(src, dst), shutil.copytree(src, dst), shutil.move(src, dst), shutil.rmtree(path), os.chmod(), os.symlink(), os.link(), os.readlink(), os.stat(), os.scandir(), os.walk(), os.rename(), os.replace(), os.remove() os.mkdir(), os.makedirs(), os.path.abspath(path), os.path.basename(path), os.path.dirname(path), os.path.isfile(path), os.path.isdir(path), os.path.splitext(path), os.path.getsize(path), os.path.relpath(path, parent), os.path.join(parent, name)

Third-party libraries support Path objects

django (in settings for example)

pandas.read_csv(path)

PIL.Image.open(path)

pytest.main([path])

click.File()(path)

Using pathlib

Creating Path objects


>>> from pathlib import Path
>>> notes_path = Path("Documents/notes.txt")
>>> notes_path
WindowsPath('documents/notes.txt')
>>> notes_path2 = Path(r"documents\notes.txt")
>>> notes_path2
WindowsPath('documents/notes.txt')
>>> notes_path2 == notes_path
True
          

>>> from pathlib import Path
>>> home = Path.home()
>>> path1 = home.joinpath(".config.toml")
>>> path2 = home / ".config.toml"
>>> path3 = Path(home, ".config.toml")
>>> path3
PosixPath('/home/trey/.config.toml')
>>> path1 == path2 == path3
True
          

Consolidated functionality

pathlib Traditional approach Returns
Path(name) os.path.normpath(name) Path
path.resolve() os.path.abspath(path) Path
path.name os.path.basename(path) str
path.parent os.path.dirname(path) Path
path.suffix os.path.splitext(path)[1] str
path.stem os.path.splitext(path)[0] str
path.is_file() os.path.isfile(path) bool
parent / name os.path.join(parent, name) Path

Cheat Sheets

pym.dev/pathlib-module

pyref.dev/pathlib#corresponding-tools

This isn't just about pathlib

Extending pathlib.Path


import os
import pathlib

class BetterPath(pathlib.Path):
    def chdir(self):
        os.chdir(self)
          

Python 3.12+ officially supports pathlib inheritance


>>> BetterPath.home().chdir()
          

>>> git_path = GitPath("src/main.py", repo_root=".")
>>> git_path.parent  # repo_root is preserved when creating new paths
GitPath('/home/user/project/src', repo_root='/home/user/project')
          

class GitPath(pathlib.Path):
    def __init__(self, *segments, repo_root):
        super().__init__(*segments)
        self.repo_root = pathlib.Path(repo_root).resolve()

    def with_segments(self, *args):
        """Ensure any derived paths remember the repo root."""
        return type(self)(*args, repo_root=self.repo_root)

    def relative_to_repo(self):
        return self.relative_to(self.repo_root or self.root)

    def __repr__(self):
        return f"{type(self).__name__}({str(self)!r}, repo_root={self.repo_root!r})"
          

๐Ÿฆ†

  • Lists and tuples are sequences
  • Dictionaries are mappings
  • Files are file-like objects
  • pathlib.Path objects are Path-like objects

PEP 519

  • Defines the os.PathLike protocol
  • Built-in functions like open() accept path-like objects
  • Any object with a __fspath__() method works
  • Third-party libraries use os.fspath() paths to strings
  • Enables third-party path-like objects

import os

class MyPath:
    def __init__(self, path):
        self.path = os.fspath(path)
    
    def __fspath__(self):
        return self.path

my_path = MyPath("example.txt")

with open(my_path) as file:
    content = file.read()
          

pathlib anti-patterns

Using the open method


path = Path("example.txt")
with path.open() as file:
    contents = file.read()
          

path = Path("example.txt")
with open(path) as file:
    contents = file.read()
          

The open method is a relic from an earlier time

Unnecessary string conversion


>>> print(f"Reading: {path}")
Reading: example.txt
          

path = Path("example.txt")
with open(str(path)) as f:
    content = f.read()
          

path = Path("example.txt")
with open(path) as f:
    content = f.read()
          

Better Path constructor usage


directory = "/home/trey/project"
filename = ".editorconfig"
config = Path(directory).joinpath(filename)
config = Path(directory) / filename
config = Path(directory, filename)
          

Works whether directory is a string or Path object

Use pathlib. You won't regret it.

  • Cross-platform compatibility built-in
  • High-level APIs for common operations
  • Easier type-checking
  • More readable code

"But pathlib is slow"

Yes, it can be slower for some operations

400,000 files searched

0.91 seconds with os.walk()

0.85 seconds with pathlib.Path().walk()

2.22 seconds when converting back to pathlib.Path()

Don't optimize parts of your code that aren't bottlenecks

pathlib

just another way to represent paths

the ideal way to represent file paths

Thanks!

trey.io/PyBeach2025

Trey Hunner
Python Team Trainer

Gradual adoption


# Start by converting at function boundaries
def process_config(config_path):
    path = Path(config_path)  # Works with strings OR Path objects
    return path.read_text()

# Legacy code still works
process_config("/path/to/config.txt")  # string path
process_config(Path("config.txt"))      # Path object

# Mix and match as you migrate
import os
for filename in os.listdir(directory):
    file_path = Path(directory) / filename  # pathlib joining
    if file_path.suffix == '.py':           # pathlib properties
        with open(file_path) as f:          # builtin functions accept Path
            content = f.read()
          

The Path class accepts strings and other Path objects

Using pathlib.Path with argparse


# For immediate file opening:
parser.add_argument("input", type=argparse.FileType("r"))
# args.input is already an open file object

# For flexible path handling:
parser.add_argument("path", type=Path)
# args.path is a Path object

if args.path.is_dir():
    ...  # Directory given
elif args.path.is_file():
    ...  # Existing file given
else:
    ...  # Path doesn't represent a file or a directory!
          

More extension examples


# Add common_prefix functionality
class BetterPath(pathlib.Path):
    def common_prefix(self, other):
        return self.with_segments(os.path.commonprefix((self, other)))

# Enhanced rmdir with recursive option
class BetterPath(pathlib.Path):
    def rmdir(self, *, recursive=False, ignore_errors=False):
        if recursive:
            shutil.rmtree(self, ignore_errors=ignore_errors)
        else:
            try:
                super().rmdir()
            except Exception:
                if not ignore_errors:
                    raise
          

Real third-party example: plumbum


from plumbum import local

# This is a plumbum Path, not a pathlib Path
my_file = local.path("example.txt")

# But it works with open() because it has __fspath__
with open(my_file) as f:
    content = f.read()

# It also works with os.path functions
import os.path
print(os.path.exists(my_file))  # Works!