Skip to content
Python

Python pathlib: A Practical Guide

By The EbookWale Team · Updated September 10, 2026 · 25 min read

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 Path for normal filesystem work, pure path classes for syntax-only calculations, and keep os or shutil where 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 .parent do not query the operating system.
  • Filesystem operations inspect or change actual entries. Calling stat(), read_text(), mkdir(), or move() can perform I/O and raise an OSError subclass.

The class choice follows that distinction:

ClassWhat it representsWhen to use it
PathA concrete path using the current platform’s rulesNormal application scripts and filesystem work
PurePathA path using the current platform’s syntax, without I/O methodsSyntax-only processing when the platform should be selected automatically
PurePosixPathPOSIX path syntax without I/OProcessing POSIX paths on any operating system
PureWindowsPathWindows path syntax without I/OProcessing 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.

Where pure paths stopPath syntaxPurePathjoin · name · parentPathsyntax plus I/OI/O boundaryFilesystemexists()read_text()mkdir()PurePath cannot cross
Pure paths stop at syntax; concrete Paths can cross the I/O boundary.

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.

A path is an address, not a buildingPath objectreports/weekly.txtDescription onlyFile existsPath is missingAccess is deniedAn operation reveals which state is real.
A Path describes an address; only filesystem access discovers what is there.

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
⚠️ GOTCHA —

An absolute child replaces the preceding path. The slash operator does not force an absolute component to remain inside the base directory.

An absolute child resets the joinbase/srv/my-app+absolute child/etc/settings.inidiscardedresult/etc/settings.ini
An absolute component replaces everything accumulated before it.

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:

The dangerous gap between check and useProcess AProcess Bexists()returns Trueremoves orreplaces fileopen()can failunprotected time gapSafer model: attempt open() and handle its exception
A successful check says nothing about what will still be true at the later open.
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.

Two meanings of “make this absolute”input spellingconfig / .. / settings.tomlabsolute()adds current directory/work/config/../settings.tomlresolve()removes .. and follows links/work/settings.tomlThe left side preserves syntax; the right side answers where it leads.
absolute() anchors the spelling; resolve() discovers the destination.

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:

MethodBest useRecursionOrderingScan errorsSymbolic links
iterdir()Every direct childNoUnspecifiedRaises if the directory cannot be scannedReturns link entries as children
glob(pattern)Pattern matches relative to one rootOnly when the pattern requests itUnspecifiedPython 3.14 suppresses scan OSError exceptionsDoes not follow links while expanding ** by default
rglob(pattern)Recursive pattern searchYesUnspecifiedPython 3.14 suppresses scan OSError exceptionsDoes not recurse through directory links by default
walk()Traversal that needs pruning, reordering, or error handlingYesFilesystem-dependentIgnores scan errors by default; on_error can receive themDoes 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.

dirnames is the walker's route mapproject/.git/src/tests/__pycache__/main.pytest_app.pyprunedpruneddirnames[:] = ["src", "tests"]
Editing dirnames in place changes which branches walk() will enter.

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.

How following links creates a loopproject/assets/back-to-projectsymbolic linkTraversal returnsto project/ againA cycle policy remembers directories already visited.
Following a link back to an ancestor turns a tree into a cycle.

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:

MethodDestination meaningExisting destination
copy(target)Exact new pathReplaces 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 nameThe 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 pathMay silently replace a file on Unix; raises FileExistsError on Windows when the target exists
replace(target)Exact new pathUnconditionally replaces an existing file or empty directory
move(target)Exact new pathOverwrites when both entries are files; rejects a non-empty target directory
move_into(directory)Existing directory that receives the source nameFollows move() behavior
unlink()The current file or symbolic linkRaises FileNotFoundError unless missing_ok=True
rmdir()The current directoryRequires 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.

⚠️ GOTCHA —

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.

One rename call, two platform outcomestarget already existssource.rename(target)Unixmay replace the fileWindowsraises FileExistsErrorreplace(target): replacement is intentional
rename() branches by platform; replace() states the overwrite intent.

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.

Cleanup stays inside an owned boundaryTemporaryDirectory owns this subtreeworkspace/source/release/contextclosesclean upother pathsuntouchedThe ownership boundary supplies the deletion target.
TemporaryDirectory gives automatic cleanup a clearly owned boundary.

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 operationTypical 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.

Does the argument name a path or a container?source/move(target)target is the final pathrelease/app/move_into(directory)directory receives source namerelease/source/The existing destination changes which collisions are possible.
Exact destinations and receiving directories mean different things.

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:

Some path spelling carries behaviorOriginal stringPath valuedirectory markerfolder/separator gonefolderrun from here./programprefix goneprogramThe normalized value cannot restore the lost intent.
Path normalization can erase details that older code treats as behavior.
  • 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 to Path("folder").
  • An executable path must retain its leading ./; Path("./program") normalizes to Path("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:

  1. Record the oldest supported Python version.
  2. Separate lexical transformations from filesystem operations.
  3. Replace string joins with /, then test absolute child components.
  4. Pass Path objects directly only where the receiving API accepts os.PathLike.
  5. Compare missing-path, permission, overwrite, and existing-target behavior.
  6. Add deterministic sorting where tests or output depend on directory order.
  7. Test symbolic links and destination collisions on every supported operating system.
  8. Preserve os.path, glob, or shutil calls 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() before open() and assuming the file cannot change between the calls.
  • Using exists(), is_file(), or is_dir() when the difference between missing and inaccessible matters.
  • Forgetting that write_text() and write_bytes() overwrite existing files.
  • Expecting iterdir(), glob(), rglob(), or walk() 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 ./program into 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.