Python pathlib: A Practical Guide
Python pathlib provides readable, object-oriented tools for building, inspecting, finding, copying, moving, and safely opening filesystem paths.
Python’s pathlib module represents filesystem paths as objects and gives those objects methods for common file and directory work. It replaces fragile string assembly with explicit operations such as path / "report.txt", while keeping failures, overwrites, ordering, and symbolic-link behavior visible.
The short version: Use
Pathfor normal filesystem work, pure path classes for syntax-only calculations, and keeposorshutilwhere pathlib would change required behavior.
What Python pathlib Is and Which Path Class to Use
pathlib has been part of Python’s standard library since Python 3.4. Its central idea is simple: a path is a value with path-specific properties and methods, rather than an ordinary string that application code must split and join manually.
Creating a path does not create or inspect anything:
from pathlib import Path
report = Path("reports") / "weekly.txt"
At this point, report is a Path object describing a possible location. The file may exist, may be missing, or may be inaccessible. The constructor performs no existence check.
There are two categories of operation:
- Lexical operations calculate from path syntax alone. Joining components, reading
.name, and finding.parentdo not query the operating system. - Filesystem operations inspect or change actual entries. Calling
stat(),read_text(),mkdir(), ormove()can perform I/O and raise anOSErrorsubclass.
The class choice follows that distinction:
| Class | What it represents | When to use it |
|---|---|---|
Path | A concrete path using the current platform’s rules | Normal application scripts and filesystem work |
PurePath | A path using the current platform’s syntax, without I/O methods | Syntax-only processing when the platform should be selected automatically |
PurePosixPath | POSIX path syntax without I/O | Processing POSIX paths on any operating system |
PureWindowsPath | Windows path syntax without I/O | Processing Windows drive or UNC paths on any operating system |
Use Path unless the code deliberately manipulates the syntax of paths belonging to another platform. Path chooses the concrete class for the system running the program. A pure path cannot call exists() or read_text() because doing so would require filesystem access.
Path objects implement os.PathLike. Libraries that accept path-like values can receive them directly:
import json
from pathlib import Path
config_path = Path("config") / "app.json"
with config_path.open(encoding="utf-8") as stream:
config = json.load(stream)
Call str(config_path) only when an older or third-party API specifically demands a string.
Use a map as the pathlib mental model
Think of a Path object as an address written on a card. Reading the street name from the card is lexical work. Visiting the address to see whether a building exists is filesystem work. Constructing the card never guarantees that the building exists.
That distinction prevents a common mistake: treating a well-formed path as proof that a usable file is present.
Build and Inspect Paths Without String Manipulation
Path() accepts path components, Path.cwd() returns the current working directory, and Path.home() returns the current user’s home directory:
from pathlib import Path
relative_log = Path("logs", "app.log")
working_log = Path.cwd() / "logs" / "app.log"
user_config = Path.home() / ".config" / "sample-app" / "settings.json"
The slash operator joins components using the path flavor’s rules. joinpath() performs the same kind of operation and is useful when components already arrive as separate arguments:
base = Path("project")
first = base / "src" / "main.py"
second = base.joinpath("src", "main.py")
assert first == second
A relative path has no complete filesystem anchor. Its meaning depends on the current working directory when a filesystem operation uses it. An absolute path has the root information required by its path flavor. On Windows, that usually includes both a drive or UNC share and a root.
Path properties replace many string operations:
from pathlib import PurePosixPath
archive = PurePosixPath("/srv/backups/project.tar.gz")
assert archive.name == "project.tar.gz"
assert archive.stem == "project.tar"
assert archive.suffix == ".gz"
assert archive.suffixes == [".tar", ".gz"]
assert archive.parent == PurePosixPath("/srv/backups")
assert archive.parents[0] == PurePosixPath("/srv/backups")
assert archive.parents[1] == PurePosixPath("/srv")
assert archive.parts == ("/", "srv", "backups", "project.tar.gz")
.parent returns one logical parent. .parents is a sequence of logical ancestors. .parts exposes the parsed components. These are lexical results, so they do not confirm that any directory exists.
Avoid the absolute-component trap
If the right operand in a join is absolute, pathlib discards the path before it:
from pathlib import PurePosixPath
base = PurePosixPath("/srv/my-app")
result = base / "/etc/settings.ini"
assert result == PurePosixPath("/etc/settings.ini")
This becomes dangerous when a supposedly relative child comes from configuration or user input. To prevent only this absolute-component reset, reject an absolute child before joining:
from pathlib import Path
base = Path("/srv/my-app")
child = Path("/etc/settings.ini")
if child.is_absolute():
raise ValueError("Expected a relative path")
target = base / child
An absolute child replaces the preceding path. The slash operator does not force an absolute component to remain inside the base directory.
Path syntax also reflects the selected flavor. PureWindowsPath understands drives and UNC paths, while PurePosixPath follows POSIX syntax. This does not make every constructed path portable. Case sensitivity, reserved names, drives, UNC shares, and symbolic-link behavior still depend on the operating system and filesystem.
For experimenting with these properties before touching real files, the Python REPL provides a quick feedback loop.
Check, Resolve, Read, and Write Files Safely
Use exists(), is_file(), and is_dir() for simple questions:
from pathlib import Path
candidate = Path("data") / "customers.csv"
if candidate.is_file():
rows = candidate.read_text(encoding="utf-8")
In Python 3.14, these file-type query methods return False when the path is missing, invalid, or inaccessible because of an operating-system error. That makes them convenient for a yes-or-no check, but it hides the difference between absence and denied access.
Use stat() when the error matters:
from pathlib import Path
candidate = Path("data") / "customers.csv"
try:
status = candidate.stat()
except FileNotFoundError:
status = None
except PermissionError as error:
raise RuntimeError(f"Cannot inspect {candidate}") from error
stat() returns filesystem metadata and normally follows symbolic links. Its exact fields come from os.stat_result; common fields include st_size and modification-time data.
Do not rely on exists() followed by open() for correctness. Another process can remove, replace, or change the file between those calls. This is a check-then-act race. Attempt the real operation and handle its exception:
from pathlib import Path
settings = Path("config") / "settings.txt"
try:
with settings.open("r", encoding="utf-8") as stream:
contents = stream.read()
except FileNotFoundError:
contents = "default=true\n"
Choose between absolute() and resolve()
absolute() makes a path absolute without resolving symbolic links or removing .. components. resolve() makes it absolute, follows symbolic links, and removes ...
from pathlib import Path
candidate = Path("config") / ".." / "settings.toml"
absolute_form = candidate.absolute()
resolved_form = candidate.resolve(strict=False)
With strict=False, the default, resolve() resolves as much of the path as possible and appends any nonexistent remainder. With strict=True, a missing component or symbolic-link problem raises OSError.
Use absolute() when the goal is to anchor the spelling to the current directory without changing its lexical structure. Use resolve() when the physical destination and symbolic links matter. Do not use a lexical .parent chain as a substitute for resolving a path that contains .. or symbolic links.
Read and write text or bytes
The convenience methods open, process, and close the file:
from pathlib import Path
notes = Path("notes.txt")
payload = Path("packet.bin")
notes.write_text("Path objects keep file code readable.\n", encoding="utf-8")
text = notes.read_text(encoding="utf-8")
payload.write_bytes(b"\x00\x01\x02")
data = payload.read_bytes()
write_text() and write_bytes() overwrite an existing file with the same name. They also require the parent directory to exist. For append mode, exclusive creation, streaming, or finer control, use open():
from pathlib import Path
audit_log = Path("logs") / "audit.log"
audit_log.parent.mkdir(parents=True, exist_ok=True)
with audit_log.open("a", encoding="utf-8") as stream:
stream.write("configuration loaded\n")
Specify an encoding for application text so behavior does not depend on the machine’s default encoding. Use bytes methods for data that should not be decoded as text.
Create, Find, and Walk Through Directories
mkdir() creates directories. The safe, common pattern for a directory tree is:
from pathlib import Path
output_dir = Path("build") / "reports"
output_dir.mkdir(parents=True, exist_ok=True)
parents=True creates missing ancestors. exist_ok=True accepts an existing directory, but still raises FileExistsError if that path exists as something other than a directory. Without parents=True, a missing parent raises FileNotFoundError.
touch() creates an empty file. If the file exists and exist_ok remains true, the operation succeeds and updates its modification time. Use touch(exist_ok=False) when an existing entry should be treated as an error.
Choose a directory-search method according to the question being asked:
| Method | Best use | Recursion | Ordering | Scan errors | Symbolic links |
|---|---|---|---|---|---|
iterdir() | Every direct child | No | Unspecified | Raises if the directory cannot be scanned | Returns link entries as children |
glob(pattern) | Pattern matches relative to one root | Only when the pattern requests it | Unspecified | Python 3.14 suppresses scan OSError exceptions | Does not follow links while expanding ** by default |
rglob(pattern) | Recursive pattern search | Yes | Unspecified | Python 3.14 suppresses scan OSError exceptions | Does not recurse through directory links by default |
walk() | Traversal that needs pruning, reordering, or error handling | Yes | Filesystem-dependent | Ignores scan errors by default; on_error can receive them | Does not follow links by default and puts directory links in filenames |
Sort results whenever deterministic order matters:
from pathlib import Path
root = Path("src")
direct_children = sorted(root.iterdir())
direct_python_files = sorted(root.glob("*.py"))
all_python_files = sorted(root.rglob("*.py"))
glob() and rglob() can hide unreadable directories because scan errors, including PermissionError, are suppressed in Python 3.14. Use iterdir() when failure to scan one known directory should be visible. Use walk(on_error=...) when recursively traversing a tree and error policy must be explicit.
Path.walk() was added in Python 3.12. It yields (dirpath, dirnames, filenames) tuples. dirpath is a Path; the other two values contain names, so join them back to dirpath when a complete path is needed:
from pathlib import Path
root = Path("project")
def stop_on_error(error):
raise error
for directory, dirnames, filenames in root.walk(on_error=stop_on_error):
dirnames[:] = sorted(
name for name in dirnames if name not in {".git", "__pycache__"}
)
for filename in sorted(filenames):
file_path = directory / filename
if file_path.suffix == ".py":
pass
Because this is a top-down walk, changing dirnames in place prunes excluded directories and imposes an order. Assign to the existing list with dirnames[:]; rebinding the local name would not control traversal.
By default, Path.walk() does not follow symbolic links. A symbolic link to a directory appears in filenames, unlike its default classification in os.walk(). Following directory links can create cycles, so code that enables that behavior needs its own cycle policy.
The optional case_sensitive argument for glob matching was added in Python 3.12. The optional recurse_symlinks argument was added in Python 3.13. Code supporting Python 3.11 or earlier must avoid those arguments, and it must use os.walk() instead of Path.walk().
Copy, Move, Rename, and Delete Paths
Python 3.14 added Path.copy(), copy_into(), move(), and move_into(). Projects supporting older Python versions should keep using shutil.copy(), shutil.copy2(), shutil.copytree(), or shutil.move(), according to the behavior they need.
The methods differ in destination meaning and replacement behavior:
| Method | Destination meaning | Existing destination |
|---|---|---|
copy(target) | Exact new path | Replaces an existing file when copying a file; a directory copy requires the exact destination not to exist, and the destination parent must already exist |
copy_into(directory) | Existing directory that receives the source name | The receiving directory must exist; a file copy can replace a file with the source name, but a directory copy fails if a directory with that name exists |
rename(target) | Exact new path | May silently replace a file on Unix; raises FileExistsError on Windows when the target exists |
replace(target) | Exact new path | Unconditionally replaces an existing file or empty directory |
move(target) | Exact new path | Overwrites when both entries are files; rejects a non-empty target directory |
move_into(directory) | Existing directory that receives the source name | Follows move() behavior |
unlink() | The current file or symbolic link | Raises FileNotFoundError unless missing_ok=True |
rmdir() | The current directory | Requires the directory to be empty |
copy() follows a source symbolic link by default and copies its target. Pass follow_symlinks=False to recreate the link at the destination. Directory structure and file data are guaranteed by default. Pass preserve_metadata=True when permissions, flags, timestamps, and extended attributes should also be copied where the platform supports them.
move() handles same-filesystem and cross-filesystem moves. On the same filesystem it uses replacement behavior. Across filesystems it copies while preserving metadata and symbolic links, then removes the source.
Choose replace() when replacement is intentional. Avoid using rename() as if it had one cross-platform collision rule.
rename() handles an existing target differently on Unix and Windows. Use replace() when replacement is part of the operation, or reject an existing target explicitly when overwriting would lose data.
Run one failure-aware Python 3.14 example
This canonical example uses a temporary directory, deterministic sorting, explicit text encoding, controlled overwrite, recursive walking, Python 3.14 copy and move methods, and non-following symbolic-link copy behavior. The temporary directory is removed automatically after the context manager closes.
from pathlib import Path
from tempfile import TemporaryDirectory
def build_release(workspace: Path) -> Path:
source = workspace / "source"
source.mkdir(parents=True, exist_ok=False)
readme = source / "README.txt"
readme.write_text("Pathlib release example\n", encoding="utf-8")
assets = source / "assets"
assets.mkdir()
(assets / "logo.bin").write_bytes(b"\x89PNG")
# Handle the operation itself instead of racing through exists() first.
try:
text = readme.read_text(encoding="utf-8")
except (FileNotFoundError, PermissionError) as error:
raise RuntimeError(f"Cannot read {readme}") from error
if not text.startswith("Pathlib"):
raise ValueError("Unexpected README contents")
# stat() preserves the reason for failure instead of reducing it to False.
try:
if readme.stat().st_size == 0:
raise ValueError("README must not be empty")
except OSError as error:
raise RuntimeError(f"Cannot inspect {readme}") from error
python_files = sorted(source.rglob("*.py"))
assert python_files == []
visited_files: list[Path] = []
def fail_walk(error: OSError) -> None:
raise error
for directory, dirnames, filenames in source.walk(on_error=fail_walk):
dirnames[:] = sorted(dirnames)
visited_files.extend(directory / name for name in sorted(filenames))
assert readme in visited_files
assert assets / "logo.bin" in visited_files
copies = workspace / "copies"
copies.mkdir()
copied_source = source.copy_into(copies, preserve_metadata=True)
assert copied_source == copies / "source"
# write_text() overwrites, so make that decision explicit.
copied_readme = copied_source / "README.txt"
copied_readme.write_text("Release copy\n", encoding="utf-8")
# A link may be unavailable under local platform or permission policy.
link = workspace / "readme-link"
link_copy = workspace / "readme-link-copy"
try:
link.symlink_to(readme)
link.copy(link_copy, follow_symlinks=False)
assert link_copy.is_symlink()
except (OSError, NotImplementedError):
pass
release_dir = workspace / "release"
release_dir.mkdir()
moved_source = copied_source.move_into(release_dir)
assert moved_source == release_dir / "source"
assert not copied_source.exists()
return moved_source
with TemporaryDirectory() as temporary_name:
final_release = build_release(Path(temporary_name))
assert (final_release / "README.txt").is_file()
The example never recursively deletes an arbitrary path. TemporaryDirectory owns cleanup, which keeps a demonstration from turning into a reusable deletion hazard.
For direct deletion, use unlink() only for a file or symbolic link. rmdir() handles an empty directory. Removing a non-empty tree remains a job for an operation with explicit recursive-deletion intent, such as shutil.rmtree(), after validating the target.
Migrate From os.path Without Subtle Regressions
A mechanical replacement can change behavior. Start by mapping each operation, then check how the old code treats separators, links, errors, and return types.
| Existing operation | Typical pathlib form |
|---|---|
os.path.join(base, name) | Path(base) / name |
os.path.basename(value) | Path(value).name |
os.path.dirname(value) | Path(value).parent |
os.path.splitext(value) | Path(value).stem and .suffix |
os.path.exists(value) | Path(value).exists() |
os.path.isfile(value) | Path(value).is_file() |
os.path.isdir(value) | Path(value).is_dir() |
os.path.realpath(value) | Path(value).resolve() |
glob.glob(pattern) | Path(root).glob(relative_pattern) |
glob.glob(pattern, recursive=True) | Path(root).glob(pattern) or .rglob(pattern) |
os.walk(root) | Path(root).walk() in Python 3.12 or newer |
shutil.move(source, target) | Path(source).move(target) for an exact destination, or Path(source).move_into(target) when target is an existing directory, in Python 3.14 or newer |
When migrating, test collisions with existing destination directories because the two pathlib methods make the destination intent explicit.
Do not assume that Path.resolve() is identical to every old normalization sequence. It resolves symbolic links and removes ... Path.absolute() makes a path absolute without those transformations.
Keep os, os.path, glob, or shutil when the code depends on behavior pathlib does not preserve:
- The application uses bytes paths rather than text paths.
- An operation uses a path relative to a directory descriptor.
- A trailing separator is significant, because
Path("folder/")normalizes toPath("folder"). - An executable path must retain its leading
./;Path("./program")normalizes toPath("program"), which can change executable lookup. - The supported Python version predates the pathlib API required by the migration.
- Existing glob behavior around hidden files, path prefixes, recursion, or symbolic links must remain exact.
A safe migration checklist is short enough to use during review:
- Record the oldest supported Python version.
- Separate lexical transformations from filesystem operations.
- Replace string joins with
/, then test absolute child components. - Pass
Pathobjects directly only where the receiving API acceptsos.PathLike. - Compare missing-path, permission, overwrite, and existing-target behavior.
- Add deterministic sorting where tests or output depend on directory order.
- Test symbolic links and destination collisions on every supported operating system.
- Preserve
os.path,glob, orshutilcalls whose exact behavior the program requires.
If path code is being migrated inside an existing project, isolate its dependencies in a Python virtual environment and test the oldest supported interpreter. The broader Python learning roadmap places filesystem work after core values, functions, exceptions, and context managers, which are the concepts used throughout pathlib.
What Python pathlib Mistakes Cause Real Failures?
- Treating
Path("file.txt")as proof that the file exists. - Joining an unvalidated absolute child and accidentally discarding the intended base path.
- Calling
exists()beforeopen()and assuming the file cannot change between the calls. - Using
exists(),is_file(), oris_dir()when the difference between missing and inaccessible matters. - Forgetting that
write_text()andwrite_bytes()overwrite existing files. - Expecting
iterdir(),glob(),rglob(), orwalk()to return a stable order. - Assuming
glob()reports every unreadable directory instead of suppressing scan errors. - Following symbolic links during recursion without a cycle policy.
- Expecting
rename()to handle an existing destination identically on Unix and Windows. - Migrating meaningful trailing separators or
./programinto normalized Path objects. - Using Python 3.12 or 3.14 methods without checking the project’s oldest runtime.
These problems overlap with several broader common Python mistakes: ignoring exceptions, relying on implicit defaults, and confusing an object that describes a resource with the resource itself.
Where the Books Fit
Python in Three Months is the right next step when pathlib needs to become part of dependable application code rather than a collection of remembered methods. Its job-ready path connects filesystem work with exceptions, context managers, testing, project structure, and compatibility decisions, so each operation has a clear failure policy.
Frequently asked questions
What is pathlib in Python?
pathlib is a standard-library module for representing filesystem paths as objects. A Path object can build and inspect paths, while its filesystem methods can read, write, search, copy, move, and delete entries.
Should I use pathlib instead of os.path?
Use pathlib for most new code because Path objects keep path construction and filesystem operations readable. Keep os.path or os when you need bytes paths, directory descriptors, significant trailing separators, or an executable path that must retain its leading './'.
Does creating a Path object create a file?
No. Path('report.txt') creates an object that describes a path, without checking or changing the filesystem. Methods such as touch(), write_text(), mkdir(), and copy() perform filesystem operations.
What is the difference between Path.absolute() and Path.resolve()?
absolute() makes a path absolute without normalizing it or resolving symbolic links. resolve() makes it absolute, resolves symbolic links, and removes '..' components; strict=True also requires the complete path to exist.
Does pathlib work the same on Windows and POSIX systems?
Path selects the correct concrete class for the current operating system, and joining components avoids hard-coded separators. Filesystem behavior can still differ in drive handling, UNC paths, case sensitivity, reserved names, symbolic links, and rename rules.