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

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
| Attribute | data/2026/report.csv |
|---|---|
.name | report.csv |
.stem | report |
.suffix | .csv |
.parent | data/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
- Without
parents=True,mkdir()raisesFileNotFoundErrorwhen an intermediate folder is missing; withoutexist_ok=True, it raisesFileExistsErrorif the folder is already there. rename()may fail on Windows if the target exists;replace()overwrites on every platform.rmdir()raisesOSError: [Errno 39] Directory not emptyon a folder with contents. Useshutil.rmtree(path)for that, carefully.- Copying: Python 3.14 added
Path.copy()andPath.move(). On earlier versions useshutil.copy2(src, dst), which acceptsPathobjects.
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
TypeError: can only concatenate str (not "PosixPath") to str: you used+. Use/to join, or an f-string if you really want text.FileNotFoundErroralthough the file is there: the working directory is not what you think. PrintPath.cwd()andp.resolve(), then anchor the path as shown above.- Backslashes eaten on Windows: in
"C:\new\table.csv",\nand\tare escape sequences. Writer"C:\new\table.csv", forward slashes, orPath("C:/") / "new" / "table.csv". p.suffix == "CSV"is never true: the suffix includes the dot. Compare withp.suffix.lower() == ".csv".
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
- Python documentation, pathlib: Object-oriented filesystem paths.
- Python Enhancement Proposals, PEP 428: The pathlib module, object-oriented filesystem paths.
ToolsConvert PDF and DOCX
Convert PDF to Word with pdf2docx and Word to PDF with docx2pdf or headless LibreOffice from Python, including batch jobs, Linux servers and output checks.
DataUnicodeDecodeError in read_csv
Why pd.read_csv raises UnicodeDecodeError: 'utf-8' codec can't decode byte, and how to fix it with the right encoding, BOM handling and UTF-8 conversion.
ToolsInstall packages in PyCharm
Install Python packages in PyCharm via the Packages tool window, interpreter settings or pip; venv vs conda, requirements.txt and fixing import errors.