Module P-3·26 min read

Auto-increment vs UUIDv4 vs UUIDv7/ULID vs Snowflake — why random keys shred B-tree page locality, and what a monotonic public ID leaks.

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

Module P-3 — Primary Keys and Distributed ID Generation

What this module covers: The primary key is a physical layout decision disguised as a naming one, and it becomes near-irreversible once a table is large. This module covers the four real candidates — auto-increment, UUIDv4, UUIDv7/ULID, and Snowflake — with the cost of each named; the mechanism by which a random key turns the whole index into the write working set, including mid-tree page splits and WAL full-page-write amplification; the arithmetic of 16 bytes versus 36 and where it multiplies; Snowflake's bit layout and the clock regression that breaks it; and the volume leak a monotonic public ID hands to anyone holding two of them.


The Key Is Where Access Pattern Beats Storage Medium, in Reverse

Module F-2 ended on a claim: access pattern beats storage medium by a wider margin than people expect. Module P-2 uses it constructively — LSM-trees turn random writes into sequential ones and pay in read amplification. This module runs it backwards. A random primary key is one line in a CREATE TABLE that converts a sequential write pattern into a random one, deliberately, forever. Nobody frames it that way; they choose it because crypto.randomUUID() needs no round trip and lets the client mint the key before the row exists. Those benefits are real. The bill arrives at a size where you can no longer change your mind.

The pager symptom is slow and recognisable. Insert throughput slides over months rather than falling off a cliff. WAL volume grows out of proportion to the data written, so replication lag (Module P-5) and backup windows both stretch. The buffer cache hit ratio on one index degrades while everything else looks fine. The index reaches roughly double the size its row count justifies; a REINDEX reclaims most of it, and it comes back. No query is slow enough to surface in pg_stat_statements.

Analogy: a warehouse worker putting away arriving stock. With sequential keys every box goes onto the end of the last row — the worker stays in one aisle all shift, and the shelf they are filling is already open in front of them. With random keys each box's slot is drawn from a hat spanning ten acres: they drive the length of the building for every box, no aisle stays warm, and a shelf that turns out to be full means dragging in an empty one and moving half the existing boxes across before the new box fits.


The Four Candidates, and What Each One Costs

SizeOrderingCoordinationLeaksIts cost
Auto-increment / identity8 bytesTotal, monotonicOne authority per tableVolume; enumerableNo client-side generation; right-edge contention; range allocation across shards
UUIDv416 bytesNoneNoneNothingDestroys insert locality — the next two sections
UUIDv7 / ULID16 bytesk-sortable by millisecondNoneCreation time, to the millisecondNot a total order; same-millisecond ties can invert between nodes
Snowflake (64-bit)8 bytesk-sortableMachine-ID assignment at startupCreation time; per-node rateA backwards clock issues duplicate keys; you operate an ID service

Auto-increment is the default and the default is usually right: an 8-byte key, perfect right-edge locality, the cheapest index entries available, comparison in one machine instruction. Three costs, all real.

It is a coordination point. The ID does not exist until the database says so, so you cannot assemble a graph of related rows in memory and insert it in one shot, cannot let an offline client create records, and cannot build an outbox row referencing its entity before the insert (Module A-3). Across shards it is worse: two shards handing out ID 1 is a merge you cannot perform, so you allocate disjoint ranges or per-node offset-and-step — coordination you must maintain, which quietly caps how many shards you can add (Module P-6).

It has a hot spot. Every insert targets the same rightmost index page. At normal concurrency that is the point; at extreme insert concurrency it becomes contention — the honest counter-argument to "sequential is always better."

It reports your size to anyone who can read it — the last section.

Against that is the one thing a server-side sequence cannot do: a client-generated key makes a retry idempotent. A retried request carries the same ID, and INSERT ... ON CONFLICT DO NOTHING turns a duplicate submission into a no-op. With a sequence you need a separate idempotency key and somewhere to store it (Module P-16). That is a genuine win for UUID-shaped keys, to be weighed rather than dismissed.


How One Random Column Makes the Whole Index the Write Working Set

Module F-13 gave the structure — a B-tree with values in sorted order at the leaves. Module F-2 gave the unit — Postgres reads and writes 8 KB pages. The mechanism falls out with no further machinery.

With a monotonically increasing key, every insert belongs at the right edge. The leaf you need is the one you needed last time: already in shared_buffers, already dirty, and it absorbs many rows before it fills. The set of pages being written is a handful of pages, however large the table.

With a random key, the target leaf is drawn uniformly from every leaf in the index. The set of pages being written is the entire index. Same row, same size, same complexity class — but the physical pattern went from "one page repeatedly" to "any page unpredictably," the transition F-2 says costs most. Three costs follow, and they compound.

Buffer-pool miss rate. Take a 500-million-row table whose primary key index is roughly 20 GB, on a machine with shared_buffers near 8 GB shared with everything else. A randomly chosen leaf is resident perhaps 40% of the time, so most inserts begin with an ~50 µs NVMe page read before any writing happens — and each read evicts something, so the rest of the workload's hit ratio degrades too. With a sequential key that read happens approximately never. The ratio is the point, not the digits.

Page splits everywhere instead of nowhere. Postgres treats a split at the rightmost page specially, leaving the left side nearly full because it can see inserts are ascending and nothing will arrive to fill the gap. Ascending keys therefore pack leaves close to full. Random keys cause ordinary mid-tree splits, each leaving two half-empty pages, and the index settles at a steady-state fill of roughly 50–70%. That is the bloat — not dead tuples, just permanently half-used pages — and a bigger index means more pages competing for the buffer pool, feeding back into the miss rate.

WAL amplification, the large one. With full_page_writes on — the default, and what protects you from torn pages on a crash — the first modification of a page after a checkpoint writes the whole 8 KB page image into the WAL, not just the delta. Sequential inserts touch few distinct pages between checkpoints, so those images are rare and amortised over many rows. Random inserts touch a different page nearly every time, so a large fraction of inserts drags an 8 KB image into the WAL to record a ~100-byte row. That is one to two orders of magnitude more WAL for the same data, and WAL is not a local cost: it streams to every replica, fills your archive, and gets replayed by a restore.

None of this is special to the primary key — an index on (tenant_id, created_at) where tenant_id is a v4 UUID scatters identically.

Why this matters in production: no tool that hunts for slow queries will find this, because no query is slow — the insert still returns in single-digit milliseconds. What changed is the rate at which the system absorbs inserts and the WAL bytes each one costs. You find it by measuring those, not on a latency dashboard. And you find it at 400 GB, when changing the key means rewriting the table and every index referencing it, online, under traffic (Module O-4).


Sixteen Bytes or Thirty-Six, and Where That Multiplies

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.