A slotted page lets rows move, but only within the page

Postgres identifies a row by (page, slot), for example (1523, 4). Why not a plain address?

A memory pointer dies with the process, so a durable address has to mean something in the file. A raw byte offset in the file would work until a row has to move, and rows do move: deletes leave holes, and compaction slides the surviving rows together. Moving a row would then mean updating every reference to it, and nothing tells you where those are. Indexes are keyed by column values, so there's no reverse map from a row back to the index entries that point at it.

So the address needs one level of indirection. The slot is not a position in the page. It's an entry in a small directory at the top of the page, and that entry holds the row's current byte offset. When the page is compacted, row 4 moves and directory entry 4 is rewritten to match. The index still says (1523, 4) and never finds out. Everything that changed lives in the page that was being rewritten anyway.

That indirection covers only half the address. The slot is indirect; the page number is not. Moving a row to a different page changes its address, and you're back to finding every index entry that points at it. So space freed by deletes can be reused by new rows landing in the same page, but half-empty pages are never merged and the file never shrinks, short of rewriting the table and all its indexes.

The slot directory had to be explained to me; I'd been reading "slot 4" as a fixed position. The limit was my own objection: that this only lets you defragment within the page. It turned out to be the whole limit.

The layout is in the PostgreSQL docs, "Database Page Layout".