Errors, exceptions and files · about 40 min

Paths and the file system with pathlib

Why this matters

Real programs work with more than one file: process every .csv in a folder, write output next to the input, create a directory for results, skip files that are too big, check whether a config exists before reading it. Doing this with string manipulation (folder + "/" + name) is fragile: separators differ between Windows and Unix, and slicing off extensions by hand goes wrong on names like archive.tar.gz. The standard library's pathlib module represents paths as objects with methods for every common operation. It is the modern, recommended way to handle the file system in Python.

A quick model of the file system

On Linux and macOS, all files live in one tree rooted at /. A path names a location in that tree:

/
├── etc/
│   └── hostname
└── home/
    └── ann/
        ├── notes.txt
        └── reports/
            └── 2026.csv

A directory is itself a special file listing names and the inodes (on-disk records holding size, permissions, timestamps and data location) they refer to. Listing a directory reads those entries; their order is whatever the file system keeps internally.

Path objects

from pathlib import Path

p = Path("/home/ann/reports/2026.csv")
print(p.name)      # 2026.csv
print(p.stem)      # 2026
print(p.suffix)    # .csv
print(p.parent)    # /home/ann/reports
print(p.parts)     # ('/', 'home', 'ann', 'reports', '2026.csv')

from pathlib import Path imports just the Path name from the module. On Linux, Path(...) creates a PosixPath; on Windows a WindowsPath with backslash handling. Your code is the same on both.

Creating a Path does not touch the disk: it is just a value describing a location, which may or may not exist. Methods like exists() or read_text() are where the system calls happen.

Building paths with /

The / operator joins path components, inserting the right separator:

base = Path("data")
out = base / "results" / "summary.txt"
print(out)                      # data/results/summary.txt
print(Path.home() / ".config")  # /home/ann/.config

This is operator overloading: Path defines what / means for its objects (module 12 shows how). Joining with an absolute path on the right discards the left side, as the operating system would: Path("data") / "/etc" is /etc.

Changing parts

p = Path("reports/2026.csv")
p.with_suffix(".json")     # reports/2026.json
p.with_name("2025.csv")    # reports/2025.csv
p.with_stem("2026-final")  # reports/2026-final.csv

Paths are immutable; these return new Path objects. Convert to a plain string with str(p) when an API needs one (most of the standard library, including open, accepts Path objects directly).

Asking the file system

p = Path("notes.txt")
p.exists()       # True if anything is there
p.is_file()      # True if it is a regular file
p.is_dir()       # True if it is a directory
p.stat().st_size # size in bytes (a stat() system call)
p.resolve()      # absolute path with symlinks and .. resolved
Path.cwd()       # the current working directory

stat() returns the inode metadata: st_size, st_mtime (last modification time as seconds since 1970), permissions in st_mode, and more. It raises FileNotFoundError if the path does not exist.

Reading and writing whole files

For small files, Path has one-call shortcuts that open, read or write, and close:

config = Path("settings.txt")
config.write_text("theme=dark\n", encoding="utf-8")   # creates or overwrites
text = config.read_text(encoding="utf-8")
print(text.strip())                                    # theme=dark

raw = Path("logo.png").read_bytes()                    # bytes, for binary files

write_text behaves like open(..., "w"): it overwrites. For line-by-line work on big files, or appending, use p.open(...) (same arguments as open) in a with statement as in the last lesson.

Listing directories

iterdir() yields the entries directly inside a directory, as Path objects:

for entry in sorted(Path("docs").iterdir()):
    kind = "dir " if entry.is_dir() else "file"
    print(kind, entry.name)
file a.txt
file b.md
file notes.txt
dir  sub

Always sort when order matters. iterdir and glob yield entries in file-system order, which differs between file systems and machines. Unsorted output is a classic source of "works on my machine" test failures.

Pattern matching with glob

glob(pattern) yields paths matching a shell-style pattern:

PatternMatches
*.txtnames ending in .txt in this directory
data_??.csvdata_ + exactly two characters + .csv
[ab]*names starting with a or b
**/*.py.py files in this directory and all subdirectories
for csv_file in sorted(Path("reports").glob("*.csv")):
    print(csv_file.name)

py_count = sum(1 for _ in Path(".").rglob("*.py"))   # rglob = glob("**/" + pattern)

Like the other tools in this module, glob returns a lazy generator: wrap it in sorted() or list() if you need to use the results more than once.

Creating, moving and deleting

out = Path("output/2026/november")
out.mkdir(parents=True, exist_ok=True)   # create with missing parents; fine if it exists

src = Path("draft.txt")
src.rename(out / "final.txt")            # move or rename (same file system)

(out / "final.txt").unlink()             # delete a file
out.rmdir()                              # delete an empty directory

Warning: Do not add or remove entries in a directory while iterating it with iterdir(); the result is undefined. Collect the paths into a list first, then act on them.

Common mistakes

Building paths with string concatenation. folder + "/" + name breaks when folder already ends in /, or on Windows. Use Path(folder) / name.

Forgetting that Path is not a string. Path("a.txt") + ".bak" raises TypeError: unsupported operand type(s) for +: 'PosixPath' and 'str'. Use p.with_suffix(...), p.with_name(...), or Path(str(p) + ".bak").

Unsorted listings. Output order from iterdir/glob is not alphabetical. Sort.

Relative paths and the cwd. Path("data.txt") is relative to where the program was started. To find files next to your script, use Path(__file__).parent / "data.txt" (__file__ is the script's own path; the modules module explains it).

mkdir errors. FileExistsError on rerun means you need exist_ok=True; FileNotFoundError means you need parents=True.

Recap

Going further: read the pathlib documentation's table "Corresponding tools" that maps os and os.path functions to Path methods, so you can read older code. Try Path("/proc/self").resolve() on Linux to see your own process id.

Exercises

Exercise 1 · quiz

Path components

For p = Path("/home/ann/reports/2026/summary.final.txt"), what is p.stem?

Answer
  • summary.final

p.name is the last component, summary.final.txt. p.suffix is the last extension, .txt (and p.suffixes is ['.final', '.txt']). p.stem is the name without its last suffix: summary.final. p.parent is /home/ann/reports/2026.

Exercise 2 · quiz

Order of directory listings

Why should you write sorted(folder.glob("*.txt")) rather than relying on the order folder.glob("*.txt") yields?

Answer
  • The order comes from the file system's internal directory structure and is not guaranteed to be alphabetical or stable across machines

Directory listings (iterdir, glob, os.listdir) return entries in whatever order the file system stores them: often creation order or a hash order, differing between ext4, btrfs, tmpfs and others. Sort whenever order matters, especially in output and tests.

Exercise 3 · write code

Inventory a folder

The current directory contains a folder docs with some files and a subfolder. Write a program that prints, for each file (not directory) directly inside docs, in alphabetical order by name, a line name size where size is in bytes (path.stat().st_size). Then print a final line total: N files, M bytes.

The output for the provided folder is:

a.txt 6
b.md 12
notes.txt 0
total: 3 files, 18 bytes

Use pathlib.Path, iterdir(), is_file() and sorted.

File: main.py

Starting point

from pathlib import Path

for p in Path("docs").iterdir():
    print(p)

Example

expected output

a.txt 6
b.md 12
notes.txt 0
total: 3 files, 18 bytes
Hint 1

sorted(Path("docs").iterdir()) sorts the paths by name.

Hint 2

Skip directories with if p.is_file():.

Hint 3

Print p.name, not p, to get just the file name.

Reference solution
from pathlib import Path

count = 0
total = 0
for p in sorted(Path("docs").iterdir()):
    if p.is_file():
        size = p.stat().st_size
        print(p.name, size)
        count += 1
        total += size
print(f"total: {count} files, {total} bytes")

Exercise 4 · write code

Sort files into folders by extension

In solution.py, write organize(folder), where folder is a string or Path. For every file directly inside folder that has an extension, move it into a subfolder named after the extension without the dot, lowercased (photo.JPG goes to jpg/photo.JPG), creating the subfolder if needed. Files without an extension stay where they are. Directories are left alone.

Return a sorted list of strings describing the moves, each "<name> -> <subfolder>/<name>".

organize("inbox")
# ['notes.txt -> txt/notes.txt', 'photo.JPG -> jpg/photo.JPG', 'song.mp3 -> mp3/song.mp3']

Useful: Path.suffix, Path.mkdir(exist_ok=True), Path.rename(target) (moves a file) and the / operator to build paths. Collect the list of files before moving anything, so you do not iterate a directory while changing it.

File: solution.py

Starting point

from pathlib import Path

def organize(folder):
    return []
Hint 1

p.suffix is '.JPG' for photo.JPG; p.suffix[1:].lower() is 'jpg'.

Hint 2

(folder / sub).mkdir(exist_ok=True) creates the subfolder unless it exists.

Hint 3

p.rename(folder / sub / p.name) moves the file.

Reference solution
from pathlib import Path

def organize(folder):
    folder = Path(folder)
    moves = []
    files = [p for p in folder.iterdir() if p.is_file()]
    for p in files:
        if not p.suffix:
            continue
        sub = p.suffix[1:].lower()
        target_dir = folder / sub
        target_dir.mkdir(exist_ok=True)
        p.rename(target_dir / p.name)
        moves.append(f"{p.name} -> {sub}/{p.name}")
    return sorted(moves)