Safe Cross-Platform Path Handling in Python with pathlib
A practical guide to using pathlib for file existence checks, extension manipulation, directory navigation, and path resolution without introducing platform-specific bugs on Windows or Unix systems.
On this page
The short answer
pathlib separates pure path objects (no I/O) from concrete ones (system calls), letting you manipulate Windows paths on Unix safely. Always guard file access with try/except around open rather than relying solely on exists(), because the filesystem can change between check and use. Use with_suffix, with_stem, and with_name for extension changes, iterdir and glob for traversal, resolve for normalization, and pass pathlib objects directly to os functions via os.PathLike.
Pure Paths Versus Concrete Paths: Choosing the Right Class
pathlib divides its API into two families. PurePath and its subclasses (PurePosixPath, PureWindowsPath) perform only string-level computations such as joining, splitting, and suffix replacement, issuing zero system calls. Path, PosixPath, and WindowsPath inherit from the pure classes and add I/O methods like open, read_text, and mkdir.
The critical cross-platform insight is that you can instantiate any pure flavour on any operating system. On a Linux machine you cannot create a WindowsPath because that would attempt a Windows-specific system call, but PureWindowsPath works everywhere. This lets you validate, normalize, or transform a Windows UNC path inside a Unix-only test environment without touching the filesystem.
According to the pathlib documentation, pure paths are useful when you want to manipulate Windows paths on a Unix machine or when you need to guarantee your code never performs OS-accessing operations. Concrete paths should be reserved for the moment you actually need to interact with the filesystem.
from pathlib import Path, PureWindowsPath, PurePosixPath, UnsupportedOperation, NotImplementedError # noqa: F401 (illustrative imports only)Practical tip
When building a path from user-supplied segments, call resolve(strict=False) to collapse '..' components and make the path absolute, but do not treat that as sufficient: also verify the resolved path lies inside an allowed base directory (for example, compare Path(base).resolve() with os.path.commonpath or Path.is_relative_to), and remember that a symlink can change between resolution and the write, so re-check or open with the resolved path immediately.
Checking Existence and Type Without Race Conditions
exists(), is_file(), and is_dir() return booleans by calling stat under the hood. They are convenient for quick diagnostics but introduce a time-of-check-to-time-of-use race: another process or thread may delete or replace the file between the check and the subsequent open. The pathlib docs note that concrete path methods can raise OSError if a system call fails.
The safer pattern is to attempt the operation directly inside a try/except OSError block. If you must check first (for example, to decide whether to create or read), keep the window between check and action as small as possible and still wrap the action in exception handling.
from pathlib import Path
target = Path('output/report.csv')
# Preferred: attempt and handle failure
try:
content = target.read_text(encoding='utf-8')
except FileNotFoundError:
print(f'{target} does not exist yet')
except PermissionError:
print(f'No permission to read {target}')
except OSError as exc:
print(f'OS error on {target}: {exc}')Changing Extensions Safely with with_suffix, with_name, and with_stem
with_suffix replaces the existing suffix or appends one if none exists. Passing an empty string removes the suffix entirely. The method only looks at the final dot segment, so for a file named archive.tar.gz the suffix is '.gz' and with_suffix('.bz2') yields archive.tar.bz2, not archive.bz2.
with_name replaces the entire final component including any suffix. with_stem (added in Python 3.9) changes only the part before the suffix, preserving it. Both raise ValueError when the path has no name component, such as a bare drive root like C:/.
A common mistake is assuming with_suffix handles multi-dot extensions like .tar.gz as a unit. It does not; you must use with_name or manual stem construction for compound extensions.
from pathlib import PureWindowsPath
p = PureWindowsPath('c:/Downloads/archive.tar.gz')
print(p.with_suffix('.bz2')) # c:/Downloads/archive.tar.bz2
print(p.with_stem('backup')) # c:/Downloads/backup.gz
print(p.with_name('new.txt')) # c:/Downloads/new.txtNavigating Directory Trees with iterdir and glob
glob accepts shell-style patterns; '' means this directory and all subdirectories recursively, and recursive=True is the default, so glob('/*.py') searches the whole tree without an explicit flag. rglob is a shorthand for glob('**/pattern').
Results from iterdir and glob are returned in arbitrary order and may include hidden entries (dotfiles on Unix, files with the hidden attribute on Windows). If you need deterministic ordering, sort the results explicitly. Whether glob follows symbolic links can vary by Python version, so verify the behavior against the pathlib documentation for the version you are using rather than assuming a fixed rule.
from pathlib import Path
data_dir = Path('output')
for p in sorted(data_dir.iterdir()):
if p.is_file() and p.suffix == '.csv':
print(p.resolve())
# Recursive search for all Python files
for py in data_dir.rglob('*.py'):
print(py)Resolving Paths: absolute, resolve, expanduser, and home
absolute() prepends the current working directory without normalizing dot-dot segments or following symlinks. resolve() eliminates '..' components and follows every symlink it encounters, returning a canonical path. In Python 3.6 the strict parameter was added; with strict=True a missing path or symlink loop raises OSError, while the default strict=False resolves as far as possible and appends the remainder.
expanduser() replaces a leading tilde or tilde-user construct with the corresponding home directory. home() is a classmethod that returns the current user home path directly. Both raise RuntimeError when the home directory cannot be determined.
from pathlib import Path
p = Path('docs/../setup.py')
print(p.resolve()) # /home/user/project/setup.py
q = Path('~/notes.txt')
print(q.expanduser()) # /home/user/notes.txt
print(Path.home()) # /home/userCross-Platform Properties: drive, root, parts, and Case Sensitivity
The drive property returns a Windows drive letter or UNC share string, and is always empty on POSIX. root returns the leading slash or backslash. parts decomposes the path into a tuple of components, grouping drive and root into a single entry on Windows.
PureWindowsPath folds case in equality and ordering comparisons, so PureWindowsPath('FOO') equals PureWindowsPath('foo'). PurePosixPath is case-sensitive. This distinction matters when building sets or dicts of paths that must behave consistently across platforms.
from pathlib import PureWindowsPath, PurePosixPath
w = PureWindowsPath('C:/Users/alice/docs')
print(w.parts) # ('C:\\', 'Users', 'alice', 'docs')
print(w.drive) # 'c:'
print(w.root) # '\\'
print(PureWindowsPath('FOO') == PureWindowsPath('foo')) # True
print(PurePosixPath('FOO') == PurePosixPath('foo')) # FalseHandling Path Errors: OSError, UnsupportedOperation, and Platform Restrictions
Concrete path methods that touch the filesystem raise OSError (or subclasses such as FileNotFoundError, PermissionError) when the underlying system call fails. resolve(strict=True) raises OSError for nonexistent paths or symlink loops. Starting with Python 3.13, PosixPath raises UnsupportedOperation when instantiated on Windows, and WindowsPath raises it on non-Windows platforms. Previously these raised NotImplementedError.
UnsupportedOperation inherits from NotImplementedError, so catching NotImplementedError will also catch it, but explicit handling is clearer. When writing code that must run on both platforms, prefer Path over PosixPath or WindowsPath to avoid accidental instantiation of the wrong flavour.
from pathlib import Path
try:
Path('/nonexistent').resolve(strict=True)
except OSError as exc:
print(f'Resolution failed: {exc}')
# On Python 3.13+, this raises UnsupportedOperation on Linux:
# from pathlib import WindowsPath
# WindowsPath('C:/')Integrating pathlib Objects with os Module Functions
PurePath implements os.PathLike since Python 3.6, so any pathlib object can be passed directly to os.listdir, os.stat, os.remove, os.symlink, and similar functions without conversion. This lets you mix pathlib convenience with os-level operations that lack a pathlib equivalent.
os.name returns 'posix' or 'nt' and is the standard way to branch on platform when pathlib alone does not expose enough information, such as checking whether os.symlink requires elevated privileges on Windows or whether a function supports the dir_fd parameter.
import os
from pathlib import Path
p = Path('/tmp/example')
# pathlib object works directly with os functions
os.makedirs(p, exist_ok=True)
os.remove(p / 'old.txt')
if os.name == 'nt':
print('Windows: symlinks need Developer Mode')
else:
print('Unix: symlinks available without elevation')Things to check
- PureWindowsPath can be instantiated on Linux without raising an error, confirming pure paths perform no system calls
- with_suffix('.bz2') on archive.tar.gz produces archive.tar.bz2, not archive.bz2, because only the last dot segment is treated as the suffix
- with_name on a path with no name component (e.g. PureWindowsPath('c:/')) raises ValueError
- resolve(strict=True) raises OSError when the path does not exist, while the default strict=False resolves partially
- PureWindowsPath('FOO') == PureWindowsPath('foo') evaluates to True while the same comparison with PurePosixPath is False
- os.listdir accepts a pathlib Path object directly due to os.PathLike support since Python 3.6
- On Python 3.13, PosixPath instantiated on Windows raises UnsupportedOperation rather than NotImplementedError
- iterdir and glob return results in arbitrary order and may include hidden entries depending on platform
Where this applies
This guide covers only the pathlib standard library and does not address third-party path libraries such as pydantic or fsspec. Symlink creation on Windows requires Developer Mode or administrator privileges; the guide notes this constraint but does not provide a workaround. os.PathLike integration is shown for common os functions only; platform-specific extensions like os.setxattr are out of scope. The json module source was not used because it does not contribute to path manipulation guidance.