Why UUIDv7 is quietly replacing autoincrement IDs
Autoincrement IDs can't be minted client-side and leak how many rows you have. Random UUIDs trash your index. UUIDv7 is the boring fix almost nobody noticed shipping.
Why it exists
For most of the last twenty years, “what should the primary key be?” had two boring answers and an argument between them.
- Autoincrement integer. The database hands out 1, 2, 3, …. Tiny, fast,
human-readable. But you can’t mint one client-side — the ID only exists
after the row does — you fight for a single counter on hot inserts, and
the IDs leak how many users (or orders, or invoices) you have. Anyone who’s seen a competitor’s
?id=URL go up by 30,000 in a week knows the leak is real. - UUIDv4. 128 bits of randomness, generated client-side, no coordination needed. Solves every distributed-systems problem the integer caused. Then quietly destroys your database’s insert performance, because rows arrive in random order and your B-tree index has to splatter writes all over the place.
Engineers picked one and lived with the trade-off. UUIDv7 is the version that finally gets to stop picking. It keeps everything that made v4 nice — client-generated, no coordination, collision-free in practice — and puts a timestamp at the front, so the IDs sort in roughly the order they were created. That one change makes them play nicely with B-trees again.
It’s specified in RFC 9562 (May 2024), which replaced the old UUID RFC 4122 and added v6, v7, and v8. v7 is the one people actually adopted.
Why it matters now
The trade-off used to be tolerable because services were small and inserts were rare relative to reads. Two things changed:
- Distributed defaults. Microservices, edge functions, mobile clients, event streams — every layer wants to mint IDs without a network round-trip to a central counter. UUIDs win that fight by default.
- Insert-heavy workloads. Event sourcing, append-only logs, audit tables, agentic systems generating tool-call records — many modern apps insert vastly more than they update. The cost of an unsorted primary key shows up as real money.
Postgres 18 (released September 2025) shipped a built-in
uuidv7()
function. Prisma added UUIDv7 schema support in 2024. MySQL and SQLite
don’t ship native v7 generators yet — their built-in UUID() functions
predate v7 — so for now you get v7 there via libraries or manual
generation. The trajectory is clear enough that “use UUIDv7” is becoming
the unremarkable default for new schemas, which is exactly when it’s
worth understanding why.
The short answer
UUIDv7 = 48-bit Unix-millisecond timestamp + 74 random bits (with version & variant tags)
A v7 UUID is just a UUID-shaped wrapper around “what time is it” and “some randomness.” Because the timestamp sits at the most-significant end, two v7s generated a millisecond apart sort in the right order; two generated in the same millisecond fall back to randomness for tie-breaking. You get v4-style independence and B-tree-friendly ordering at the same time.
How it works
The 128-bit layout, roughly (the version and variant nibbles take a few bits out of the random fields):
| 48-bit unix_ts_ms | ver=7 | 12 bits rand_a | var | 62 bits rand_b |
Two properties fall out of that layout:
- Lexicographic order ≈ creation order. Because the timestamp is the high-order bytes, comparing two v7s as raw bytes (or as strings, since the canonical hex form preserves order) gives you “older first” almost for free. New rows insert at the right edge of the B-tree, which is the cheap case: hot pages stay in cache, splits happen at the end, and the index doesn’t fragment.
- No coordination needed. Each generator just reads the clock and rolls up to 74 random bits. (The spec lets implementations spend some of those bits on sub-ms timestamp resolution or a monotonic counter; whatever is left is random.) For a fully random v7, two services on two continents producing one each in the same millisecond collide with probability ~1 in 2^74 — for any workload that fits on Earth, you can treat that as zero. Implementations that trade randomness for monotonicity have a smaller pool, but it’s still cosmically large.
Why v4 hurts your database
To see why ordering matters, watch a B-tree index take inserts. The leaf pages are stored in primary-key order on disk. With an autoincrement key, every new row goes at the end — one hot page, always in memory, always the target. With v4 UUIDs, each new row’s key is a random number between 0 and 2^128, so it lands in a random leaf page. That page probably isn’t in cache. The database faults it in, mutates it, marks it dirty, eventually writes it back. Multiply by every insert. You’ve turned a sequential write workload into a random one, which on both spinning disks and SSDs is a category change in cost.
The classic symptom: a service that benchmarks fine on a fresh database gets mysteriously slower as the table grows past whatever fits in the buffer pool. That’s the point where “random page reads on every insert” stops being free.
v7 puts insertions back at the right edge. You get almost the same one-hot-page behavior as autoincrement — randomness in the low bits means inserts spread across the few most recent leaf pages rather than landing on a single one — but that’s worlds closer to sequential than v4’s scatter, and your buffer pool will feel the difference.
Why v7 over a plain timestamp + random tail
You could roll your own “timestamp prefix + random suffix” ID and skip the RFC. People did, for years — Twitter’s Snowflake, ULID, KSUID, all variations on the theme. v7 is interesting because:
- It’s still a UUID. Every library, every database column type, every
serialization format already handles
UUID. You don’t need a new type or a new validator. It drops in. - It’s standardized. Two systems generating v7s independently produce comparable, interoperable values. Snowflake and ULID don’t talk to each other; v7s do.
- It gives up the right things. It doesn’t try to encode a node ID, a shard, or a sequence number — fields that previous “ordered UUID” attempts (v1, v6) included and that turned out to be more trouble than they were worth. v7 just says: time, then noise.
Where it gets fuzzy
- Clock skew. v7 sort order is only as good as your clock. Two servers whose wall clocks disagree by 100ms will produce IDs that don’t sort globally in true causal order. For most workloads that’s fine — you’re using the order as a cache locality hint, not a logical timestamp. Don’t use v7 ordering for anything where causality matters; use a real logical clock or a sequence.
- It still leaks time. Less than autoincrement leaks count, but more
than v4 leaks anything. If “when was this row created” is sensitive,
v7 is the wrong tool. (For most app data, it isn’t sensitive — your
created_atcolumn already exposes the same thing.) - Storage size. A v7 is still 16 bytes vs. 4 or 8 for an integer. On a table with billions of rows and many indexes, that adds up. Worth measuring; usually not worth losing sleep over.
- Monotonicity within a millisecond. RFC 9562 allows but does not require generators to guarantee monotonic ordering of v7s issued in the same millisecond. Some implementations add a counter in the random_a field; others rely on randomness. If you need strict per-process monotonicity, check what your library actually does — I wouldn’t assume it without reading the source.
The mental model worth keeping: a v7 is a v4 with the high bits replaced by “now.” That single change is doing all the work.
Famous related terms
- B-tree index —
B-tree = sorted, page-oriented tree on disk— the data structure whose write pattern v7 is designed to play nicely with. - UUIDv4 —
UUIDv4 = 122 random bits + version & variant tags— collision-free but unsorted; the version v7 is replacing for primary keys. - UUIDv1 —
UUIDv1 = 60-bit timestamp + clock seq + node ID (often MAC)— an earlier attempt at sortable UUIDs; the byte ordering of the timestamp split the high bits across the value, so v1 is not lexicographically sortable. The node field traditionally carried the host’s MAC address, though the spec allows a random value when MAC isn’t available or desirable. UUIDv6 fixes the ordering; v7 abandons the node field entirely. - ULID —
ULID = 48-bit ms timestamp + 80 random bits, encoded in Crockford Base32— the pre-standard “sortable UUID-shaped thing” most adopted before v7 existed. Same timestamp idea; more random bits and a different text encoding. - Snowflake ID —
Snowflake = 1 unused/sign bit + 41-bit timestamp + 10-bit machine + 12-bit per-ms sequence in a 64-bit int— Twitter’s answer to the same problem, optimized for size and per-node monotonicity at the cost of needing assigned machine IDs. - Autoincrement —
autoincrement = database-side counter— small, fast, sequential, and the source of every “central coordinator” headache the UUID world was invented to escape.
Going deeper
- RFC 9562 (May 2024) — Universally Unique IDentifiers (UUIDs). The actual spec for v6, v7, and v8. Short and readable.
- The Postgres 18 release notes for
uuidv7()and surrounding discussion on the pgsql-hackers list — the engineering arguments for adopting it natively are worth reading even if you don’t use Postgres. - Buildkite’s “Goodbye integers, hello UUIDs” and similar migration posts from the 2010s — useful history for why people kept reinventing this before v7 existed.