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
- Python 3.10+ (a couple of methods noted below need 3.12).
- A Typer or Click CLI.
- Ideally, access to a Windows machine or a Windows CI runner to confirm the results. Testing a CLI across Python versions with GitHub Actions shows how to add one.
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.
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
Pathbecause of the annotation, validates that it exists and is a directory, andresolve_path=Truemakes 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,mkdirandparentare allPathmethods. There is noos.pathimport 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.
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.mdandreadme.mdcan 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,NULorCOM1, and characters including<>:"|?*. If your CLI generates filenames from data — titles, URLs, dates with colons — sanitise them. A timestamp like2026-09-18T10:00:00is a valid filename on Linux and invalid on Windows; use2026-09-18T100000instead. - 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, andPath("/foo")is relative to the current drive.is_absolute()handles these correctly; hand-written checks likes.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.
- Show short paths to people. Relative to the working directory where possible,
~-abbreviated otherwise — that is whatdisplay()above does. - Emit absolute or POSIX paths to machines. In
--jsonoutput, 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, andrepr()or!rin 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.