Runtime

Cross-Platform Paths with pathlib in CLIs

Use pathlib to make Python CLI path handling work on Windows, macOS and Linux: parsing arguments, joining, globbing, showing paths and writing portable output.

Updated

A CLI written on macOS works for everyone on the team until the first Windows user runs it. Then path.split("/")[-1] returns the whole path, "~/reports" creates a directory literally named ~, os.path.join(root, "data/raw") produces a mixed-separator path that some tools reject, and a manifest file written with backslashes breaks the Linux CI job that reads it. None of this is exotic; it is the everyday cost of treating paths as strings. This guide shows how to use pathlib consistently in a CLI — from argument parsing to output — so the same code behaves correctly on every platform. It is part of the filesystem topic.

Prerequisites

Paths are objects, not strings

pathlib.Path represents a path as a structured object. On Windows it is a WindowsPath; elsewhere a PosixPath. Both expose the same interface, and the operations that trip people up with strings — joining, splitting off the name, changing an extension, finding a parent — are properties and methods that know the platform's rules.

Anatomy of a Path The parts of a pathlib Path: anchor, parent, name, stem and suffix, shown for a Windows and a POSIX example. Anatomy of a Path Path("reports/2026/q3.tar.gz") pure operations .parent reports/2026 .name q3.tar.gz .stem q3.tar .suffixes ['.tar', '.gz'] No filesystem access needed The / operator joins parts Same code on Windows and POSIX Most "path bugs" are string slicing that pathlib would have done correctly.
from pathlib import Path

p = Path("reports") / "2026" / "q3.tar.gz"
print(p.parent)       # reports/2026   (reports\2026 on Windows)
print(p.name)         # q3.tar.gz
print(p.stem)         # q3.tar
print(p.suffix)       # .gz
print(p.suffixes)     # ['.tar', '.gz']
print(p.with_suffix(".zip"))        # reports/2026/q3.tar.zip
print(p.with_name("q4.tar.gz"))     # reports/2026/q4.tar.gz
print(p.as_posix())   # reports/2026/q3.tar.gz on every platform

These are pure operations: none of them touch the disk. That makes them cheap and safe to use in validation code and tests.

The recipe: pathlib from edge to edge

The rule for a CLI is simple: convert to Path at the edge where input arrives, keep it as a Path throughout, and convert to a string only at the edge where something outside Python demands one. Here is a small but complete command that follows it — it collects log files under a directory into a gzip archive:

# src/mytool/cli.py
from __future__ import annotations

import gzip
import shutil
from pathlib import Path

import typer

app = typer.Typer()


def display(p: Path, base: Path | None = None) -> str:
    """Short, recognisable form of a path for humans."""
    base = base or Path.cwd()
    try:
        return str(p.relative_to(base))
    except ValueError:
        pass
    try:
        return str(Path("~") / p.relative_to(Path.home()))
    except ValueError:
        return str(p)


@app.callback()
def main() -> None:
    """Log utilities."""


@app.command()
def bundle(
    root: Path = typer.Argument(..., exists=True, file_okay=False, resolve_path=True),
    out: Path = typer.Option(Path("logs.txt.gz"), "--out", "-o", dir_okay=False),
    pattern: str = typer.Option("*.log", help="Glob, relative to ROOT."),
) -> None:
    """Concatenate every file matching PATTERN under ROOT into one gzip file."""
    out = out.expanduser().resolve()
    files = sorted(p for p in root.rglob(pattern) if p.is_file() and p != out)
    if not files:
        typer.echo(f"no files matching {pattern!r} under {display(root)}", err=True)
        raise typer.Exit(1)
    out.parent.mkdir(parents=True, exist_ok=True)
    with gzip.open(out, "wb") as dst:
        for f in files:
            dst.write(f"# {f.relative_to(root).as_posix()}\n".encode())
            with f.open("rb") as src:
                shutil.copyfileobj(src, dst)
    typer.echo(f"bundled {len(files)} files into {display(out)}", err=True)


if __name__ == "__main__":
    app()

Walk through where the conversions happen:

  • In: Typer converts the argument to Path because of the annotation, validates that it exists and is a directory, and resolve_path=True makes it absolute. For options that may contain ~, call .expanduser() yourself — shells expand an unquoted ~, but a value from a config file or a quoted argument arrives with the tilde intact.
  • Throughout: rglob, is_file, relative_to, open, mkdir and parent are all Path methods. There is no os.path import and no string slicing.
  • Out, for machines: the header line uses .as_posix(), so the archive's contents are identical whether it was built on Windows or Linux.
  • Out, for humans: display() shows a path relative to the current directory when possible, otherwise abbreviates the home directory.
os.path to pathlib A translation table from os.path and os functions to their pathlib equivalents. os.path to pathlib os / os.path pathlib os.path.join(a, b) a / b os.path.expanduser("~") Path.home() os.makedirs(p, exist_ok=True) p.mkdir(parents=True, exist_ok=True) open(p).read() p.read_text(encoding="utf-8") glob.glob("**/*.py") p.rglob("*.py") os.path.relpath(p, root) p.relative_to(root) Keep Path objects inside your code; convert to str only at the edge that demands it.

Traps that pathlib does not remove

pathlib fixes string handling, but a few platform differences are about the filesystem itself:

  • Case sensitivity. macOS (APFS by default) and Windows are case-insensitive; Linux is not. Two files called README.md and readme.md can coexist only on Linux. When your tool compares paths, compare resolved paths, and do not assume a lookup by name is case-sensitive.
  • Reserved names and characters. Windows refuses filenames such as CON, NUL or COM1, and characters including <>:"|?*. If your CLI generates filenames from data — titles, URLs, dates with colons — sanitise them. A timestamp like 2026-09-18T10:00:00 is a valid filename on Linux and invalid on Windows; use 2026-09-18T100000 instead.
  • Path length. Older Windows configurations limit paths to 260 characters. Deeply nested output directories can hit it; keep generated structures shallow.
  • Absolute paths. Path("C:foo") on Windows is relative to the current directory on drive C, and Path("/foo") is relative to the current drive. is_absolute() handles these correctly; hand-written checks like s.startswith("/") do not.
  • Globbing and hidden files. Path.glob("*") includes dot-files on POSIX (unlike the shell). Filter explicitly if you mean to skip them.

UX considerations

How paths look matters as much as how they are handled. Users scan output for the files they care about, and a 90-character absolute path buries the part they recognise.

How should a path be shown to the user? A decision for displaying paths: relative to the current directory when it is inside it, otherwise absolute with the home directory abbreviated. How should a path be shown to the user? Is the path under the current directory? Yes — inside the project Relative src/app/cli.py No — elsewhere on disk ~/... or absolute ~/.cache/mytool/x Show short paths people recognise; log absolute ones where a machine reads them.
  • Show short paths to people. Relative to the working directory where possible, ~-abbreviated otherwise — that is what display() above does.
  • Emit absolute or POSIX paths to machines. In --json output, prefer absolute paths (unambiguous regardless of where the consumer runs) or root-relative POSIX paths (portable across machines). Pick one and document it; emitting JSON output for scripting covers keeping that contract stable.
  • Accept both separators on Windows. Path("a/b") already works on Windows, so users can paste either form. Do not reject forward slashes.
  • Quote paths in messages when they may contain spaces. error: cannot read 'My Documents/report.csv' is clearer than the unquoted version, and repr() or !r in an f-string does it for you.

Testing the behaviour

pathlib provides pure path classes you can instantiate on any platform, which lets a Linux CI job test Windows path logic without a Windows machine:

# tests/test_paths.py
from pathlib import Path, PurePosixPath, PureWindowsPath

import pytest

from mytool.cli import display


@pytest.mark.parametrize("cls", [PurePosixPath, PureWindowsPath])
def test_suffix_logic_is_platform_neutral(cls):
    p = cls("reports") / "2026" / "q3.tar.gz"
    assert p.name == "q3.tar.gz"
    assert p.with_suffix(".zip").name == "q3.tar.zip"


def test_windows_absolute_rules():
    assert PureWindowsPath(r"C:\data").is_absolute()
    assert not PureWindowsPath("C:data").is_absolute()
    assert not PureWindowsPath(r"\data").is_absolute()


def test_display_prefers_relative(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    target = tmp_path / "sub" / "x.log"
    assert display(target) == str(Path("sub") / "x.log")


def test_display_abbreviates_home(tmp_path, monkeypatch):
    home, elsewhere = tmp_path / "home", tmp_path / "elsewhere"
    home.mkdir()
    elsewhere.mkdir()
    monkeypatch.setattr(Path, "home", classmethod(lambda cls: home))
    monkeypatch.chdir(elsewhere)   # the target is not under the working directory
    assert display(home / ".cache" / "x") == str(Path("~") / ".cache" / "x")

For behaviour that depends on the real filesystem — case sensitivity, reserved names, symlinks — run the suite on each operating system in CI. That is the only reliable test, and it catches the rest of the platform differences at the same time.

Conclusion

Paths are one of the few areas where a small, mechanical discipline removes an entire category of bug reports. Convert to Path at the edge, stay with Path inside, use as_posix() for anything another machine will read, show short paths to people, and remember the handful of filesystem differences no API can hide. Pair it with storing app data with platformdirs for your tool's own files and your CLI will behave the same on every laptop your team owns.

Frequently asked questions

Should functions accept str or Path?

Accept str | os.PathLike[str] in public helpers and convert with Path(value) on the first line; return Path. That lets callers pass either, while everything inside works with one type. Inside a single application, annotating parameters as Path is simpler and perfectly fine.

Is Path.resolve() safe to call on paths that do not exist?

Yes; since Python 3.6 it resolves as much as exists and appends the rest. Pass strict=True when you want a FileNotFoundError for a missing path. Note that it follows symlinks, which you may not want when displaying paths back to the user.

How do I walk a directory tree efficiently?

Path.rglob() is convenient for simple patterns. For large trees where you want to prune directories — skipping .git or node_modules — use Path.walk() (Python 3.12+) or os.walk(), and remove names from the directory list in place to stop descent.

Why did my tool create a directory literally named ~?

Tilde expansion is a shell feature, not a filesystem one. When a path reaches Python without passing through an unquoted shell word — from a config file, an environment variable, a quoted argument or a Windows terminal — ~ is just a character, and Path("~/out").mkdir(parents=True) creates a folder called ~ in the current directory. Call .expanduser() on every path that a person might have typed, wherever it came from. It is a no-op for paths without a leading tilde, so applying it unconditionally is safe.

What about paths inside archives or on remote storage?

zipfile.Path gives a pathlib-like interface into zip files, and libraries such as fsspec or universal-pathlib extend the idea to S3 and other stores. Keep local-path code on pathlib.Path and put remote access behind its own small module.