Skip to content
← Back to explainers

Python explainer

Join Paths Safely with pathlib

About 3 min to read. Allow extra time to work through examples.

Take paths apart with pathlib, see why an absolute part replaces the path before it, and check that a joined path stays inside its folder.

This article uses Python. You can make it your practice mode to browse related content across the site.

Your web app keeps uploads under /srv/data, and each request names the file it wants. You join the name onto the folder and check that the result starts with /srv/data. A request for ../data2/secrets.env passes that check and reads a file outside the folder. The pathlib module gives you path objects with a method that makes the right check.

Start with the parts of a path:

from pathlib import PurePosixPath

path = PurePosixPath("/srv/reports/archive.tar.gz")
print(path.parent)            # /srv/reports
print(path.name)              # archive.tar.gz
print(path.stem)              # archive.tar
print(path.suffix)            # .gz
print(path.suffixes)          # ['.tar', '.gz']
print(path.with_suffix(""))   # /srv/reports/archive.tar

PurePosixPath handles paths written with forward slashes, as on Linux and macOS. A "pure" path works only on the text of the path and never touches the disk, so it behaves the same on every machine. For real files, use Path. It adds methods such as exists, mkdir, read_text and write_text. Pass encoding="utf-8" when you read or write text.

stem is only the last part of the path, without its last suffix. To drop the suffix and keep the folders, use with_suffix("").

An absolute part replaces the path before it

The / operator joins paths:

root = PurePosixPath("/srv/data")
print(root / "reports/q1.csv")  # /srv/data/reports/q1.csv
print(root / "/etc/passwd")     # /etc/passwd
print(root / "../other")        # /srv/data/../other

When the right side is absolute, it replaces everything before it. That is normal path joining, but when the part comes from a user, the result can leave your folder. Joining also keeps .. as it is.

Clean up the path, then compare whole parts

posixpath.normpath removes . and .. from a path string. Then is_relative_to checks whether one path is inside another:

import posixpath

candidate = PurePosixPath(posixpath.normpath(str(root / "a/../b")))
print(candidate)                       # /srv/data/b
print(candidate.is_relative_to(root))  # True

sibling = PurePosixPath("/srv/data2/secrets.env")
print(str(sibling).startswith(str(root)))  # True, which is wrong
print(sibling.is_relative_to(root))        # False

A common mistake is a string prefix check. The string /srv/data2 starts with /srv/data, but the folder data2 is not inside data. The is_relative_to method compares whole parts, so it sees data and data2 as different names. A path counts as relative to itself, so the root passes.

A check that looks only at the text of a path, like these two, is a lexical check.

Check your understanding Does this check make it safe to open the file? Show answer

Not on its own. A symlink, a file that points to another path, can be inside the root and point outside it, and a lexical check cannot see that. Path.resolve() follows symlinks, so comparing resolved paths checks where they point right now. The disk can still change between your check and your open. For files that untrusted users can create, also control who can write to that folder. The exercise checks only the text of the path.

Going further

  • Path.absolute() makes a path absolute without removing .. or following symlinks. Path.resolve() does both.
  • A public function that accepts both strings and path objects can annotate the parameter as str | os.PathLike[str] and convert it with Path(...).
  • Python 3.14 changed suffix for a name that ends in a dot. PurePosixPath("file.").suffix is "." from 3.14 on, and "" on 3.12 and 3.13.
  • See the pathlib documentation.

step 44 of 55

Everyday Standard Library Tools

Up next Keep a Joined Path Inside Its Root Folder