Python

Python pathlib: Build Paths, List Folders and Read Files Without os.path

Python · · Published 2026-11-09

A wooden signpost with arrows pointing to several destinations beside a red dirt track in an outback town
One post, many directions: a path is just a sequence of parts.

The pathlib module turns file paths into objects. Instead of gluing strings together with os.path.join() and slicing off extensions by hand, you write Path("data") / "report.csv", ask for .suffix or .parent, and call .read_text() or .glob() directly on the result. The same code works on Windows, macOS and Linux, because the module knows which separator each system uses.

All examples were run with Python 3.13 on Linux, so printed paths use forward slashes. On Windows the same objects print with backslashes. Nothing needs installing: pathlib has been in the standard library since Python 3.4.

Create and join paths

from pathlib import Path

p = Path("data") / "2026" / "report.csv"
print(p)        # data/2026/report.csv
print(repr(p))  # PosixPath('data/2026/report.csv')

The / operator joins parts, and you may mix Path objects and plain strings on either side. Path itself is a shortcut: on Linux and macOS it creates a PosixPath, on Windows a WindowsPath. If you need to handle the other system's paths without touching the disk, for example to parse Windows paths in a log file on a Linux server, use PureWindowsPath or PurePosixPath:

from pathlib import PureWindowsPath

PureWindowsPath(r"C:\Users\ada\notes.txt").parts
# ('C:\\', 'Users', 'ada', 'notes.txt')

One rule surprises people: joining an absolute path discards everything before it. Path("/etc") / "/usr" gives /usr, exactly like os.path.join. If a user-supplied file name might start with a slash, strip it or check the result before using it.

Take a path apart

Attributedata/2026/report.csv
.namereport.csv
.stemreport
.suffix.csv
.parentdata/2026
.parts('data', '2026', 'report.csv')

Files with two extensions need care. For archive.tar.gz, .suffix is only .gz and .stem is archive.tar; .suffixes returns ['.tar', '.gz'].

To change one part, use the with_ methods. They return a new path and never rename anything on disk:

p.with_suffix(".json")     # data/2026/report.json
p.with_name("summary.csv") # data/2026/summary.csv
p.with_stem("report-v2")   # data/2026/report-v2.csv  (Python 3.9+)

This is the cleanest way to build an output file name from an input file, for instance when converting a folder of documents as in our guide to converting PDF to DOCX with Python: out = pdf.with_suffix(".docx").

Check what exists

p = Path("data/2026/report.csv")
p.exists()   # True or False
p.is_file()  # True only for an existing regular file
p.is_dir()   # True only for an existing directory
p.stat().st_size  # size in bytes; raises FileNotFoundError if missing

Checking first and acting afterwards leaves a small window in which another process can delete or create the file. For anything that matters, just try the operation and catch FileNotFoundError or FileExistsError.

Read and write files

For small files, two calls replace the usual with open(...) block:

notes = Path("notes.txt")
notes.write_text("first line\n", encoding="utf-8")
text = notes.read_text(encoding="utf-8")

raw = Path("logo.png").read_bytes()

write_text overwrites the file, and both methods open and close it for you. Always pass encoding="utf-8": without it, Python uses the platform's default encoding, which is the root cause of many UnicodeDecodeError problems. For large files, or to append, use Path.open(), which takes the same arguments as the built-in open():

with Path("app.log").open("a", encoding="utf-8") as f:
    f.write("started\n")

A Path is accepted anywhere the standard library and most third-party packages expect a file name: open(), json.load(), pandas.read_csv(), shutil. If an old library insists on a string, pass str(p) or os.fspath(p). Our tutorial on reading and writing JSON files combines both modules: json.loads(Path("user.json").read_text(encoding="utf-8")).

Find files: iterdir, glob, rglob and walk

Given this tree:

t/
└── a/
    ├── notes.txt
    ├── two.csv
    └── b/
        └── one.csv
base = Path("t")

[p.name for p in (base / "a").iterdir()]
# ['b', 'notes.txt', 'two.csv']  (order is not guaranteed; sort it)

list(base.glob("*/*.csv"))
# [PosixPath('t/a/two.csv')]

sorted(base.rglob("*.csv"))
# [PosixPath('t/a/b/one.csv'), PosixPath('t/a/two.csv')]

iterdir() lists one directory. glob() matches a pattern relative to the path, where * never crosses a folder boundary and ** means "this folder and every folder below". rglob("*.csv") is shorthand for glob("**/*.csv"). All three return generators, so wrap them in sorted() or list() when you need a list, and remember that the order follows the file system, not the alphabet.

Since Python 3.12, glob and rglob accept case_sensitive=False, handy for files named .CSV and .csv in the same folder. Python 3.12 also added Path.walk(), the pathlib version of os.walk():

for root, dirs, files in Path("t").walk():
    print(root, dirs, files)
# t ['a'] []
# t/a ['b'] ['notes.txt', 'two.csv']
# t/a/b [] ['one.csv']

As with os.walk(), removing names from dirs inside the loop stops the walk from entering those folders, a quick way to skip .git or a virtual environment folder such as .venv.

Create, move and delete

out = Path("build/reports")
out.mkdir(parents=True, exist_ok=True)   # like mkdir -p

(out / "a.txt").write_text("hi", encoding="utf-8")
(out / "a.txt").rename(out / "b.txt")    # move or rename
(out / "b.txt").replace(out / "c.txt")   # same, but always overwrites

Path("build/old.log").unlink(missing_ok=True)  # delete a file (3.8+)
Path("build/empty-dir").rmdir()                 # delete an EMPTY folder

Paths relative to your script

A relative path like Path("config.toml") is resolved against the current working directory, which is wherever the program was started from, not where the script lives. That is why a script works in your IDE and fails from a scheduled task. Anchor files that ship with the code to the script's own location:

HERE = Path(__file__).resolve().parent
config = HERE / "config.toml"

resolve() makes the path absolute and follows symbolic links; absolute() only prepends the current directory. In a Jupyter notebook __file__ does not exist, so use Path.cwd() there. For the user's home folder, Path.home() or Path("~/data").expanduser() work on every system.

To show a short path in a message, use relative_to(): Path("t/a/two.csv").relative_to("t") gives a/two.csv. If the path is not inside the base, it raises ValueError; test first with is_relative_to() (Python 3.9+).

Common errors

Summary

Build paths with Path(...) / "part", read their pieces with .name, .stem, .suffix and .parent, derive new names with with_suffix(), find files with glob(), rglob() or walk(), create folders with mkdir(parents=True, exist_ok=True), and anchor bundled files to Path(__file__).resolve().parent. Keep encoding="utf-8" on every text read and write.

Sources