PYTHON / FILE HANDLING
File paths with pathlib
Build, inspect and transform filesystem paths with pathlib.Path: join with /, split names and suffixes, and create parent directories before writing.
What you will learn
- Join path segments with the / operator instead of string concatenation
- Split a path apart with .name, .stem, .suffix, .parent and .parts
- Create missing folders with .mkdir(parents=True, exist_ok=True) before writing
- Read and write small files directly via Path.read_text() and Path.write_text()
Understanding File paths with pathlib
A Path is not a string: it is an immutable object that stores the path as a tuple of parts plus an optional anchor (a root like / or a drive like C:\). Because it knows about parts, pathlib can implement joining as an operator: Path("data") / "reports" / "q3.csv" builds one path with exactly one separator between segments, and it inserts the separator the current operating system uses. Every method returns a new Path rather than editing the old one, so you can pass a base path around without worrying that some function mutated it.
The reason this matters is that string manipulation of paths is quietly wrong in several ways. "data" + "reports" gives "datareports", "data/" + "/reports" gives a double slash, and splitting on "." to get an extension breaks on names like archive.tar.gz or on directories named v1.2. pathlib parses the path once and exposes the pieces: .name is the last component, .suffix is the final extension, .stem is .name without that suffix, .parent is everything above it, and .with_suffix() and .with_name() rebuild a path with one piece swapped out.
pathlib also merges two things that used to live in separate modules. Path objects handle the string-shaped work that os.path did (joining, splitting, checking absoluteness), and the same object carries the filesystem work: .exists(), .is_file(), .mkdir(), .stat(), .iterdir(), .glob(), .open(), .read_text(). Constructing a Path never touches the disk, so a Path can happily describe a file that does not exist; only the methods that ask the operating system a question can fail. That split is the mental model: pure path arithmetic first, filesystem calls when you actually mean to hit the disk.
from pathlib import Path
base = Path("data") / "reports"
csv_path = base / "q3_summary.csv"
print(csv_path)
print(csv_path.name)
print(csv_path.stem)
print(csv_path.suffix)
print(csv_path.parent)
print(list(csv_path.parents))
print(csv_path.with_suffix(".json"))
print(csv_path.with_name("q4_summary.csv"))
print(csv_path.parts)
print(Path("data/reports/q3_summary.csv") == csv_path)A Path is a structured sequence of path components, so joining, renaming and re-suffixing are object operations rather than string surgery.
Worked examples
Creating directories, then writing through the Path
Builds a nested target path, makes its parent folders, and reads the file back using Path methods only.
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
target = root / "logs" / "2024" / "app.log"
print(target.exists())
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text("started\nstopped\n", encoding="utf-8")
print(target.is_file())
print(target.parent.relative_to(root))
print(target.read_text(encoding="utf-8").splitlines())
print(target.stat().st_size)Example explained
Line 1target.exists() is False right after construction: building a Path asks the disk nothing.
Line 2target.parent.mkdir(parents=True, exist_ok=True) creates logs and 2024 in one call and does not raise if they already exist.
Line 3write_text opens, writes and closes in one call, which is fine for small files; use .open() when you need to stream.
Line 4relative_to(root) strips a known prefix and raises ValueError if the path is not actually under it.
Listing files with glob and rglob
Shows the difference between matching one directory level and recursing through subdirectories.
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "notes.txt").write_text("a", encoding="utf-8")
(root / "todo.txt").write_text("b", encoding="utf-8")
(root / "image.png").write_bytes(b"\x89PNG")
(root / "archive").mkdir()
(root / "archive" / "old.txt").write_text("c", encoding="utf-8")
print([p.name for p in sorted(root.glob("*.txt"))])
print([str(p.relative_to(root)) for p in sorted(root.rglob("*.txt"))])
print(sorted(p.name for p in root.iterdir() if p.is_dir()))Example explained
Line 1glob("*.txt") matches only inside root, so archive/old.txt is not included.
Line 2rglob("*.txt") is shorthand for glob("**/*.txt") and walks into every subdirectory.
Line 3glob and iterdir return generators in unspecified order, so sorted() is what makes the output reproducible.
Line 4Each yielded item is a full Path, so .relative_to, .is_dir and .read_text work on it directly.
Windows paths, POSIX paths, and the absolute-join trap
Uses pure paths to show how anchors work and how joining an absolute segment throws away the left side.
from pathlib import PurePosixPath, PureWindowsPath
win = PureWindowsPath(r"C:\Users\ana\data\input.csv")
print(win.parts)
print(win.drive, win.root)
print(win.as_posix())
print(win.suffix)
posix = PurePosixPath("/srv/data/input.csv")
print(posix.parts)
print(posix.is_absolute())
print(PurePosixPath("/srv/data") / "/etc/passwd")Example explained
Line 1PureWindowsPath and PurePosixPath do path arithmetic for the other platform's rules without touching your filesystem.
Line 2The first element of .parts is the anchor ('C:\\' or '/'), which is why an absolute path has one more part than you might count.
Line 3as_posix() converts separators to forward slashes, useful when a path has to go into a URL or a config file.
Line 4The last line prints /etc/passwd: a right-hand operand that is absolute resets the join, so the base is silently discarded.
Important notes
str() and repr() of a Path are platform-dependent: on Linux and macOS you see PosixPath and forward slashes, on Windows WindowsPath and backslashes. Compare Path objects, not their strings.
with_suffix() only replaces the final extension, so Path("archive.tar.gz").with_suffix(".zip") gives archive.tar.zip; use .suffixes or .with_name() for multi-part extensions.
Common mistakes
Using + to join: Path("data") + "out.txt" raises TypeError: unsupported operand type(s), and str(base) + "out.txt" quietly produces "dataout.txt" with no separator.
Joining a segment that came from user input or config and happens to start with / (or a drive letter): base / value returns just value, so you read or overwrite a file completely outside your directory.
Calling write_text() on a path whose directory does not exist yet: it raises FileNotFoundError even though the path string looks correct, because pathlib will not create parents implicitly.
Try it yourself
Change, predict, then run
Write a function output_path(src) that turns "data/raw/sensor_07.csv" into "data/clean/sensor_07.json" using only pathlib (no str.replace and no split on "/" or "."), then print output_path(Path("data/raw/sensor_07.csv")).
Open the Python workspaceCheck your understanding
base = Path("/srv/app") and name = "/etc/passwd" came from a config file. What is base / name?
- PosixPath('/etc/passwd') — an absolute right-hand segment discards everything to its left
- PosixPath('/srv/app/etc/passwd') — pathlib strips the leading slash before joining
- A TypeError, because two absolute paths cannot be joined
- PosixPath('/srv/app//etc/passwd') — both separators are preserved as written
Show answer
Joining resets at any absolute segment, so the result is /etc/passwd and the base is silently gone; this is the classic path-traversal bug. Option 1 is tempting because that is what you meant, but pathlib never strips a leading slash — if you want a relative interpretation you must remove the anchor yourself (for example with lstrip("/") on the raw string) before joining.