Every filesystem task starts with a path, and pathlib turns that path into an object you can ask questions of instead of a string you have to chop up. Path knows its own parent, its own extension, whether it exists, and how to find siblings — and it does all of that with the same slash-joining syntax on every operating system.
Construct one with Path("data") and you have something better than a string. Join a child onto it with /, ask it for .name or .suffix, create the directory it lives in with .mkdir(parents=True, exist_ok=True), and read or write it with .read_text() and .write_text(). No os.path.join with three different separator characters, no manual string surgery to find the extension, no os.makedirs blowing up because the folder already exists.
Why prefer pathlib over os.path?
os.path is a collection of functions that take strings and return strings. pathlib is a class whose instances model a path. The difference shows up the moment you do more than one thing with a path: with os.path you call os.path.dirname(p), os.path.basename(p) and os.path.splitext(p) and reassemble the pieces yourself; with Path the object carries the pieces with it.
| Task | os.path | pathlib |
|---|---|---|
| join two segments | os.path.join(a, b) | a / b |
| parent folder | os.path.dirname(p) | p.parent |
| file name | os.path.basename(p) | p.name |
| extension | os.path.splitext(p)[1] | p.suffix |
| does it exist | os.path.exists(p) | p.exists() |
| read text | open(p).read() | p.read_text() |
The path object is also a better citizen. You can pass a Path straight to open(), to os.stat(), or to shutil functions without converting it back to a string, and the object stays immutable — every "modify" operation returns a new Path instead of mutating the original.
How do you build a path and inspect its parts?
A Path exposes its components as attributes. .name is the final component, .stem is that name without its last suffix, .suffix is the last suffix including the dot, and .parent is the directory containing it. .parts gives you the whole thing as a tuple, and .suffixes lists every suffix, which is what you want for something like archive.tar.gz.
from pathlib import Path
config = Path("settings") / "app.toml"
report = Path("reports") / "2026" / "q1.csv"
print("name :", config.name)
print("stem :", config.stem)
print("suffix:", config.suffix)
print("parent:", config.parent)
print("parts :", config.parts)
print("suffixes of archive.tar.gz:", Path("archive.tar.gz").suffixes)
The / operator is the joining syntax and it works even when the right-hand side looks like an absolute path, in which case the right-hand side wins. That behaviour is occasionally surprising, so build paths from small, trusted pieces rather than from strings a user typed.
How do you create a directory tree that may already exist?
mkdir() with no arguments creates exactly one directory and raises FileExistsError if it is already there. Two keyword arguments make it idempotent, which is exactly what you want in a script: parents=True creates missing intermediate directories as well, and exist_ok=True suppresses the error when the target already exists.
from pathlib import Path
import tempfile
with tempfile.TemporaryDirectory() as tmp:
target = Path(tmp) / "data" / "raw" / "2026"
target.mkdir(parents=True, exist_ok=True)
print("created:", target.exists())
target.mkdir(parents=True, exist_ok=True)
print("running the same command twice is fine")
(target / "note.txt").write_text("hello", encoding="utf-8")
print("contents:", (target / "note.txt").read_text(encoding="utf-8"))
Idempotent directory creation is the difference between a script you can run twice and a script that fails the second time. Anything a setup or deploy script does should be safe to repeat.
How do you list files by pattern?
Three methods cover most needs, and the difference between them is how deep they search. iterdir() yields the immediate children of a directory, nothing more. glob(pattern) walks the directory and matches names against a shell-style pattern, non-recursively by default. rglob(pattern) does the same but recurses into every subdirectory.
Patterns are not full regular expressions. * matches any run of characters within a single path component, ? matches exactly one character, and ** spans directories.
from pathlib import Path
import tempfile
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "src").mkdir()
(root / "src" / "main.py").write_text("print('hi')", encoding="utf-8")
(root / "src" / "util.py").write_text("x = 1", encoding="utf-8")
(root / "docs").mkdir()
(root / "docs" / "readme.md").write_text("# hi", encoding="utf-8")
print("top level:", sorted(p.name for p in root.iterdir()))
print("one level deep:", sorted(p.as_posix() for p in root.glob("src/*.py")))
print("every .py anywhere:", sorted(p.relative_to(root).as_posix() for p in root.rglob("*.py")))
Two practical notes. First, always sort() the results if you are going to print or compare them — the order that comes back from the filesystem is not guaranteed and varies between machines and even between runs. Second, glob and rglob return generators, so they are lazy; wrap them in list() or sorted() when you want to reuse the list more than once.
How do you rename, move and delete paths?
Path offers the file operations you would otherwise reach for os and shutil for. .rename(target) moves or renames, .replace(target) does the same but overwrites the destination on Windows, .unlink() deletes a file, and .unlink(missing_ok=True) does not complain if it is already gone. .touch() creates an empty file if it does not exist and otherwise updates its modification time.
Two derived-path helpers are worth memorising. with_suffix(".json") returns a new path with the extension replaced — perfect for "same file, different format" — and with_name("new.txt") swaps the final component while keeping the folder. Both return new objects; they never change the original path.
from pathlib import Path
import tempfile
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
original = root / "notes.txt"
original.write_text("data", encoding="utf-8")
markdown = original.with_suffix(".md")
print("with_suffix:", markdown.name)
print("original untouched:", original.name)
renamed = original.with_name("renamed.txt")
print("with_name:", renamed.name, "in", renamed.parent.name)
renamed.write_text("moved data", encoding="utf-8")
renamed.replace(original)
print("after replace:", sorted(p.name for p in root.iterdir()))
print("contents now:", original.read_text(encoding="utf-8"))
original.unlink(missing_ok=True)
original.unlink(missing_ok=True)
print("deleted twice with no error")
relative_to() is the odd one out that pays off quickly. Give it a parent path and it hands back the child portion, and raises ValueError if the path is not actually inside that parent. That makes it a clean way to turn an absolute path back into a relative one for display.
What to remember
- Build paths with
Path(...)and join with/. Never string-concatenate withos.sep. - Ask the object for what you need:
.name,.stem,.suffix,.parent,.parts. mkdir(parents=True, exist_ok=True)makes directory creation safe to repeat.iterdir()for one level,glob()for a pattern,rglob()for a recursive search — andsort()the output.with_suffix()andwith_name()return new paths, which is why they are safe to use in expressions.
Try it yourself: inventory a folder of files
Pick a folder on your machine with a mix of file types. Write a script that walks it with rglob and prints, for each file, its name, its extension, its size in kilobytes and its parent folder. Sort the results so the output is stable between runs.
Then add a second stage: for every .txt file found, compute a new path with with_suffix(".md") and print it, but do not actually rename anything yet. Finally, make the script safe to run twice by giving the destination directory the same mkdir(parents=True, exist_ok=True) treatment you would use in production.