Filesystem paths look simple until code has to run from a different working directory, support multiple operating systems, handle symbolic links, or distinguish path manipulation from actual filesystem access.
Python’s pathlib module provides path objects that make those distinctions explicit. Instead of repeatedly joining and splitting strings, code can express operations such as “the parent directory,” “this file’s suffix,” or “this path relative to that directory” directly.
The most useful habit is not merely replacing os.path calls with methods. It is understanding which pathlib operations are lexical, which consult the filesystem, and when resolving a path changes its meaning.
Build paths from components
For ordinary filesystem work, start with Path:
from pathlib import Path
root = Path("data")
report = root / "exports" / "report.csv"
print(report)The / operator joins path components using the path semantics of the current platform. It avoids manually inserting / or \\ separators.
A Path object also implements Python’s path-like protocol, so many standard-library APIs accept it directly. Convert with str(path) only when an API specifically requires a string.
Do not build a path by concatenating strings:
# Fragile: separator handling is now your responsibility.
report = "data/" + filenamePath joining also has an important rule: if a later component is absolute, it can replace the path built before it. Treat untrusted path components as data to validate, not as harmless suffixes to append blindly.
Inspect a path without parsing strings
Path exposes common path components directly:
from pathlib import Path
path = Path("archive/2026/report.csv")
print(path.name) # report.csv
print(path.stem) # report
print(path.suffix) # .csv
print(path.parent) # archive/2026These operations are lexical. They inspect the path representation; they do not require the target to exist.
That makes them appropriate for tasks such as choosing an output name or validating an expected extension before opening a file.
Multiple suffixes need deliberate handling
For a name such as backup.tar.gz, suffix is .gz, while suffixes exposes the sequence of recognized suffixes.
path = Path("backup.tar.gz")
print(path.suffix)
print(path.suffixes)Do not assume stem always means “the filename before every extension.” If application semantics treat .tar.gz as one compound extension, encode that rule explicitly.
Relative paths depend on the current working directory
A relative path such as:
Path("config/settings.json")is interpreted relative to the process’s current working directory when an operation accesses the filesystem.
That directory is not necessarily the directory containing the Python source file. Test runners, schedulers, service managers, containers, and users launching a command from another directory can all change the working directory.
If a resource is intentionally located relative to a module, anchor it explicitly:
from pathlib import Path
MODULE_DIR = Path(__file__).resolve().parent
config_path = MODULE_DIR / "config" / "settings.json"For command-line tools, a path supplied by the user is usually better interpreted relative to the user’s current working directory unless the interface documents another rule.
The important point is to choose the anchor intentionally rather than relying on where the program happened to be launched.
Use home and current-directory helpers when they express intent
Path.cwd() returns the current working directory, while Path.home() returns the current user’s home directory:
from pathlib import Path
working_dir = Path.cwd()
home_dir = Path.home()For a path containing a tilde, use expanduser() when shell-style home expansion is actually part of the input format:
path = Path("~/downloads/report.csv").expanduser()Python does not rely on the shell to expand ~ inside an arbitrary string passed to Path. Expansion is an explicit operation.
Distinguish absolute from resolved paths
Making a path absolute and resolving it are related but different operations.
Path.absolute() returns an absolute path without resolving symbolic links. Path.resolve() makes the path absolute, resolves symbolic links, and eliminates .. components.
from pathlib import Path
path = Path("reports/../archive/result.json")
print(path.absolute())
print(path.resolve())That difference matters whenever symbolic links can appear in the path. Purely collapsing .. text can produce a different destination from following the filesystem’s symlink structure.
Use resolve() when you need the filesystem-aware resolved location. Do not call it automatically on every path: resolving performs filesystem work and changes a lexical path into a path tied to the current filesystem state.
Choose strict resolution intentionally
resolve() accepts a strict argument. With strict=True, failure to resolve a nonexistent path raises an error. With the default non-strict behavior, resolution proceeds as far as possible and appends unresolved remainder components.
existing = Path("settings.toml").resolve(strict=True)Strict resolution is useful when the operation requires an already-existing target. It is inappropriate when constructing the path of a file that is supposed to be created later.
Do not use lexical containment as a security boundary
Path.is_relative_to() and Path.relative_to() answer lexical path questions. They do not by themselves prove that a filesystem target is safely contained beneath a directory when symbolic links are involved.
For example, checking an unresolved path such as uploads/member/file.txt says nothing about whether uploads/member is a symlink to a location outside uploads.
When containment matters for security, the design must account for filesystem resolution and for race conditions between validation and later use. A simple pattern can be useful for controlled local inputs:
from pathlib import Path
base = Path("uploads").resolve(strict=True)
candidate = (base / "images" / "avatar.png").resolve()
if not candidate.is_relative_to(base):
raise ValueError("path escapes upload directory")This checks the resolved location at that moment, but it is not a complete defense against hostile concurrent filesystem changes. An attacker who can modify directories or symlinks between validation and opening can create time-of-check/time-of-use problems.
Security-sensitive code should prefer operating-system facilities and application designs that keep trusted directory handles or otherwise avoid reopening a validated path by name. Do not present resolve() plus is_relative_to() as a universal sandbox mechanism.
Use relative_to when the relationship is required
When one path is expected to be beneath another, relative_to() expresses that requirement clearly:
from pathlib import Path
root = Path("/srv/app")
file = Path("/srv/app/static/logo.svg")
relative = file.relative_to(root)
print(relative)The result is static/logo.svg on a POSIX system.
If the path cannot be represented relative to root under the requested semantics, relative_to() raises ValueError. That is often preferable to silently manufacturing a path when containment was an application invariant.
Remember that PurePath.relative_to() is fundamentally lexical. Resolve first if your intended relationship is about actual filesystem destinations rather than path spelling.
Read and write small files directly from Path
For small text files, read_text() and write_text() can keep simple code concise:
from pathlib import Path
path = Path("message.txt")
path.write_text("ready\n", encoding="utf-8")
text = path.read_text(encoding="utf-8")Specify the encoding when the file format defines one. Relying on a platform-dependent default can make otherwise portable code behave differently across environments.
For large files or streaming processing, use Path.open() and process the file incrementally:
from pathlib import Path
path = Path("events.log")
with path.open("r", encoding="utf-8") as file:
for line in file:
process(line)Path improves path handling; it does not change the usual trade-offs between reading a whole file and streaming it.
Create directories with explicit expectations
mkdir() mirrors common directory-creation requirements:
from pathlib import Path
output = Path("build/reports")
output.mkdir(parents=True, exist_ok=True)parents=True creates missing parent directories. exist_ok=True avoids failing merely because the target directory already exists.
Those options should match the operation’s semantics. If an existing directory indicates a deployment mistake, suppressing that signal with exist_ok=True may hide a problem.
Also remember that successful existence checks do not reserve a path. Another process can create, remove, or replace filesystem entries immediately after a check.
Avoid check-then-open races
This pattern is often unnecessary:
if path.exists():
data = path.read_text(encoding="utf-8")The file can disappear between exists() and read_text(). If the real requirement is “read this file if it exists,” attempt the operation and handle the relevant exception:
try:
data = path.read_text(encoding="utf-8")
except FileNotFoundError:
data = NoneThe same principle applies to many filesystem operations. Use exists(), is_file(), and is_dir() when their answers are useful observations, but do not mistake a prior check for a guarantee about a later operation.
Be precise about symlinks
Many Path methods follow symbolic links because callers usually care about the target. is_symlink() instead asks whether the path itself is a symbolic link.
A broken symlink illustrates why the distinction matters: the directory entry can be a symlink even though its target does not exist.
When writing cleanup, deployment, or security-sensitive code, decide whether the operation concerns the link or its target. Do not infer one question from the answer to another.
Avoid following directory symlinks recursively unless that behavior is intentional. Recursive traversal that follows links can leave the expected tree and can encounter cycles.
Use PurePath for path logic without filesystem access
PurePath, PurePosixPath, and PureWindowsPath model path syntax without performing filesystem I/O.
They are useful for manipulating paths that belong to another platform or for testing lexical path rules:
from pathlib import PureWindowsPath
path = PureWindowsPath("C:/logs/app/error.log")
print(path.name)
print(path.parent)A concrete Path uses the semantics of the platform on which Python is running. Do not instantiate a concrete Windows path on POSIX and expect it to access a Windows filesystem, or vice versa.
Pure paths make the distinction explicit: sometimes a program needs to reason about a path string, not touch the local filesystem.
Accept path-like inputs in reusable APIs
Reusable functions do not need to force callers to convert every path to a string.
A practical pattern is to accept path-like input and normalize it at the boundary:
from os import PathLike
from pathlib import Path
def load_template(path: str | PathLike[str]) -> str:
return Path(path).read_text(encoding="utf-8")This keeps the function convenient for callers using strings or path objects while giving the implementation one consistent representation.
If a library function only passes the value to another path-aware API, conversion may not be necessary at all.
Common pitfalls
Assuming the working directory is the source directory
Relative filesystem paths are resolved from the process’s current working directory, not automatically from the module that contains the code.
Treating paths as portable strings
Hard-coded separators and drive assumptions leak platform details into application logic. Build paths from components and let the appropriate path class apply its syntax.
Resolving every path immediately
Resolution follows filesystem state and can change the meaning of a lexical path. Resolve when that is the question you need answered.
Using a containment check as a sandbox
Lexical path relationships do not neutralize symlinks, and even a resolved pre-check can race with hostile filesystem changes.
Checking existence as permission to act later
The filesystem can change after the check. Handle failures from the operation that actually matters.
Ignoring ownership of the path convention
A path from a command-line user, a configuration file, an archive entry, and a URL path do not necessarily follow the same interpretation rules. Define what relative paths mean at each interface.
Make path semantics part of the design
pathlib is valuable because it turns filesystem intent into explicit operations. The larger benefit comes from choosing the right operation for the question.
Build paths from components. Keep lexical manipulation separate from filesystem resolution. Anchor relative paths deliberately. Treat symbolic links and concurrent filesystem changes as real concerns when security depends on containment. Use pure paths when you only need syntax, and concrete paths when you actually need I/O.
With those distinctions in place, path-handling code becomes easier to read and less dependent on accidental properties of the machine or working directory where it happens to run.