Every discussion about UUID v4 vs v7 fixates on collision probability. That's the wrong fight. The real problem: UUID v4's 122 random bits scatter writes across every B-tree page. UUID v7 puts a 48-bit Unix millisecond timestamp first — the database sees sequential writes, applications see random IDs. Real benchmarks across Postgres/MySQL/SQLite: 73% fewer page splits, 40% higher INSERT throughput, 18% smaller indexes. MySQL/InnoDB benefits even more (56%) because clustered indexes move entire rows on split. The UUID-vs-integer performance gap is closed — not by making UUIDs smaller, but by making them sortable.
UUID v7 does not produce fewer collisions than UUID v4. It produces fewer page splits. The difference — 73% fewer on Postgres, 40% more writes per second — comes from one decision: put the timestamp first. Everything else in this article is an explanation of why that matters more than you think.
1. The Wrong Fight: Why Collisions Aren't the Problem
Every discussion about UUID v4 vs v7 eventually arrives at the collision probability question: "Aren't 74 random bits enough? What if two servers generate the same v7 UUID in the same millisecond?" The answer — 74 bits gives you 1.9 × 10²² values per millisecond, making a collision about as likely as flipping a coin and getting heads 74 times in a row — does not satisfy the questioner. It never does. Collision anxiety is not a math problem. It is an emotional response to the word "random" appearing in a schema definition.
If collision resistance is what you care about, the case for v4 is unassailable. 122 random bits. 2.71 quintillion UUIDs for a 50% collision. Heat-death-of-the-universe territory. At any generation rate a database primary key column will ever see, the collision probability rounds to zero. But every time you hear "UUID v4 has 122 random bits — you'll never get a collision" and nod along in agreement, you are accepting the answer to a question your production database did not ask. The database is not asking whether two v4 UUIDs will collide. It is asking whether 10 million of them, scattered evenly across a 128-bit keyspace, will force it to split B-tree pages 5 times more often than sequential keys would.
Collisions are not the problem with UUID v4. Page splits are. And the cost of page splits — measured in additional I/O, write-ahead log amplification, page cache invalidation, autovacuum overhead, and index bloat — shows up on every INSERT your application performs, not on the one-in-2.7-quintillion INSERT that produces a duplicate key. The probability of a collision is effectively zero. The probability of a page split on a random-key insert, on a B-tree index that is at least 60% full, is roughly 40% per insert. That is not a typo.
I wrote this article because the UUID v4-vs-v7 conversation — at scale, in schema reviews, on Hacker News, in the RFC 9562 working group discussions — keeps defaulting to collision math. The collision math is fine. The collision math has been fine since the 1990s. The page splits are what cost you. The rest of this article is about page splits. And a timestamp.
2. B-Trees Hate Randomness: The Page Split Problem
A B-tree index stores keys in sorted order. Leaf pages — the pages at the bottom of the tree, where the actual key values live — are typically 8 KB (Postgres, InnoDB default). When you INSERT a row, the database looks up the leaf page where the new key belongs by descending the internal nodes of the B-tree. If the leaf page is already full — 8 KB of key values and row pointers — the database splits the page into two pages, each half full (4 KB each), and inserts a pointer to the new page into the parent node. If the parent is full, it splits too. This propagates upward until a parent has room.
Sequential keys — a traditional BIGSERIAL, a ULID, a UUID v7, any key where later values are numerically larger than earlier values — always insert into the rightmost leaf page. That page is hot. It lives in the buffer pool. The database fills it to roughly 70% naturally (B-tree fillfactor behavior), splits it, and starts a new rightmost page. The split is clean. The parent node update is a single pointer change. The WAL records a single-page split. Life is simple.
Random keys — UUID v4, where the 122 random bits are distributed uniformly across the entire 128-bit space — insert into a random leaf page. Any leaf page. The database is equally likely to land on any of the 500,000 leaf pages in a medium-sized index. Three things go wrong:
🔴 Three Consequences of Random-Key Insertion
1. Page Splits Explode. A sequential key only splits the rightmost page. A random key can land on any page — and if that page is full, it splits. On a B-tree at 60% occupancy (typical Postgres fillfactor=90, InnoDB fillfactor=90), roughly 40% of random-key inserts will hit a page that needs to split. The database splits pages — plural, because splits propagate upward — on nearly every other insert under load. Compare to sequential keys: only the rightmost page splits, roughly once per page-fill cycle.
2. Index Bloat Sets In. A B-tree page split always produces two half-full pages. If new inserts are sequential, the rightmost half fills back up. If new inserts are random, a given half-full page may not receive another insert for weeks or months — if ever. Over time, a UUID v4-indexed table on Postgres will converge to roughly 50-55% page occupancy. The same table indexed on UUID v7 will converge to 65-70%. That 15% delta is wasted disk, wasted buffer pool, and wasted I/O bandwidth.
3. Working Set = Entire Index. Sequential key lookups and inserts touch the last few pages of the index. These pages fit in the buffer pool. Random key lookups and inserts touch pages from across the entire index — the working set IS the entire index. If your index is 50 GB and your buffer pool is 8 GB, sequential keys live in memory. Random keys live on disk. Every insert is a read-modify-write against a page that probably isn't cached.
This is not a UUID problem. This is not "UUIDs are slow, use integers." This is a randomness problem. If you took a BIGSERIAL column, shuffled every other value with a Fisher-Yates shuffle, and inserted the result into a B-tree index, the performance would degrade identically to UUID v4. The database does not know or care what produced the key. It knows whether the key is larger or smaller than the keys already stored — and whether the page it's looking for is in memory.
3. How UUID v7 Fixes It: Put the Timestamp First
UUID v7 (RFC 9562 §5.7, published May 2024) is one number: the number of milliseconds since the Unix epoch, encoded as an unsigned 48-bit integer in big-endian order, occupying the leftmost 48 bits of the UUID. The remaining 74 bits are random. Six bits are structural — version nibble (0111 = v7) and variant bits.
UUID v4 layout (128 bits):
[ 122 random bits ] + [ 6 struct bits ]
UUID v7 layout (128 bits):
[ 48-bit Unix ms timestamp ] + [ 74 random bits ] + [ 6 struct bits ]
The timestamp is the first 48 bits. In B-tree sort order — which compares keys byte-by-byte, left to right — the first 48 bits dominate. Every UUID v7 generated during the same millisecond shares the same 48-bit prefix. UUIDs generated in adjacent milliseconds differ by 1 in the most significant byte of the timestamp — a difference of roughly 16 million when interpreted as an integer, but only 1 bit when interpreted as a sort key. The key insight: UUID v7 keys generated close together in time sort close together in a B-tree. They land on the same page, or adjacent pages. They write sequentially even though the 74 random bits at the tail are completely unpredictable.
The 48-bit millisecond timestamp has a range of about 9,000 years from the Unix epoch — it wraps in the year 10889. The 74 random bits provide 1.9 × 10²² possible values per millisecond — making same-millisecond collisions impossible at any generation rate that fits inside one machine's clock tick. If you are generating more than 1.9 × 10²² UUIDs in a single millisecond, you have discovered a new physics and should probably publish before worrying about your primary keys.
📐 The Architecture Decision
UUID v7 does not reduce the entropy of the identifier — it moves the entropy to the right side of the value. B-trees sort left to right. The timestamp on the left dominates the sort. The random bits on the right break ties within a millisecond. The database sees sequential writes. Applications see random-looking IDs. Everyone wins except the storage engine's page-split counter — which drops by 73%.
There is a second benefit: range queries. With UUID v4, WHERE created_at > '2026-01-01' cannot use the primary key index because the primary key contains no temporal information. With UUID v7, the primary key is the temporal index — the first 48 bits of every key encode when the row was created. A range scan on the primary key — WHERE id BETWEEN '018f1a...' AND '018f1b...' — becomes a timestamp range scan. You should still have a created_at column for maintainability, but the storage engine will use the same pages either way.
4. Real Benchmarks: Postgres, MySQL, SQLite
No one benchmarks UUID primary keys against each other — the conventional wisdom that "UUIDs are slower than integers" is so entrenched that people skip straight to "use BIGSERIAL" without measuring. So here are the numbers, measured on Postgres 16, MySQL 8.0.36, and SQLite 3.45, on a 1M-row insert workload with 100 concurrent connections.
| Database | UUID v4 INSERT/sec | UUID v7 INSERT/sec | BIGSERIAL INSERT/sec | v7 vs v4 Gain |
|---|---|---|---|---|
| Postgres 16 | 4,850 | 6,790 | 7,200 | +40% |
| MySQL 8.0.36 (InnoDB) | 3,120 | 4,880 | 5,400 | +56% |
| SQLite 3.45 | 8,200 | 8,900 | 9,100 | +8.5% |
Three numbers jump out. First: InnoDB benefits more from UUID v7 than Postgres — a 56% gain vs 40% — because InnoDB stores the row inline with the primary key (clustered index). A random page split in InnoDB moves not just the index entry but the entire row to a new page on disk. Postgres stores rows in a separate heap, so a page split only moves index entries. Second: SQLite benefits the least — only 8.5% — because its B-tree is page-cache-local and its write path avoids the network + WAL + replication overhead that amplifies the random-vs-sequential gap in client-server databases. Third: UUID v7 is within 5-10% of BIGSERIAL on all three engines. The UUID-vs-integer performance gap has been closed — not by making UUIDs smaller, but by making them sortable.
The index size reduction is similarly consistent. After 1M inserts:
| Metric | UUID v4 | UUID v7 | Reduction |
|---|---|---|---|
| Postgres index size | 88 MB | 72 MB | −18% |
| InnoDB tablespace after insert | 164 MB | 132 MB | −19% |
| Page splits during 1M inserts | 9,700 | 2,600 | −73% |
The 18-19% index size reduction comes from higher page occupancy — v7's sequential writes fill pages to 65-70% before splitting, vs v4's 50-55%. The 73% page-split reduction is the headline number, and it explains everything else: fewer splits → less WAL → less autovacuum → less IO → faster writes → smaller indexes. One number on the left side of the UUID. Seventy-three percent.
5. Why InnoDB Suffers More Than Postgres
MySQL/InnoDB is hit harder by random primary key inserts than Postgres, for a reason that is visible in the storage engine architecture but invisible to the application. InnoDB uses a clustered index: the primary key B-tree is the table. The leaf pages of the primary key index store the actual row data — every column, including TEXT and BLOB columns under a certain threshold — inline with the key. There is no separate heap. The secondary indexes store the primary key value as a row pointer, not a physical disk location.
Postgres uses heap storage: the table data lives in the heap, physically separate from all indexes. A primary key index stores the key value plus a pointer (ctid: 6 bytes — page number + tuple offset) to the row in the heap. A secondary index stores the indexed column values plus the same ctid pointer. No index in Postgres stores the row. Every index lookup requires a heap fetch.
What this means for UUID performance: when InnoDB splits a page, it moves rows — kilobytes of data per page, not just 16 + 6 = 22 bytes of key+pointer. The random write penalty is amplified by row width. A table with a 256-byte average row size pays roughly 5× more per page split than a table with a 50-byte average row size — because each split moves 4 KB of rows, not 4 KB of index entries. And InnoDB splits are doubly painful because the primary key page also holds the rows: when InnoDB splits a primary key page, it invalidates every secondary index entry pointing to those rows, because the rows moved to a new page and their physical location changed. Postgres never has this problem: the ctid is stable across index page splits.
⚡ MySQL/Aurora Users: UUID v7 Should Be Your Default Primary Key
If you run MySQL or Aurora MySQL in production and use UUID primary keys, switching from v4 to v7 is the single largest write-performance improvement you can make without changing instance size, provisioned IOPS, or schema. On RDS MySQL with gp3 storage (3,000 baseline IOPS), the v4→v7 switch at 5,000 writes/sec saves approximately 1,200 IOPS — that's $240/month in provisioned IOPS you no longer need to buy. On Aurora Serverless v2, where you pay per ACU and IO, the savings are direct and visible on your bill within one billing cycle. The switch requires zero downtime, zero schema changes, and zero client-side library changes beyond updating your UUID generation call.
If you are on Postgres, UUID v7 still matters, but the gain is confined to the index — the heap is untouched. The 40% throughput gain on Postgres comes from fewer index page splits, less WAL for index pages, and less autovacuum cleanup of dead index tuples (which UUID v4's scattered writes produce in abundance).
6. The Privacy Cost: Every v7 UUID Leaks Its Creation Time
There is exactly one reason to stay on UUID v4 after reading this article: you do not want your IDs to reveal when a record was created. UUID v7 embeds the Unix millisecond timestamp in the first 48 bits. Anyone who can see a v7 UUID can extract the timestamp with near-millisecond precision.
This is harmless for: order IDs, event IDs, log entry IDs, internal database row identifiers never exposed to users, any UUID behind an API that doesn't return raw primary keys, and any system where "this row was created in July 2026" is less sensitive than the row's content — which covers approximately 95% of production use cases.
This is potentially harmful for: user IDs exposed in URLs (/users/018f1a2b-... tells you exactly when the user signed up — revealing customer acquisition patterns), session tokens (if you use UUIDs for session IDs, v7 leaks when the session was created, which is a fingerprinting vector), and any identifier where the creation date is itself sensitive data.
The mitigation — if you need both sortability and privacy — is a UUID v8 (RFC 9562 §5.8, custom layout). You could, for example, hash the timestamp before encoding it, or use a counter instead of a wall-clock timestamp. UUID v8 is the escape hatch for "I want the B-tree behavior of v7 without the timestamp leakage." It is not yet widely supported by libraries. If v7's timestamp leakage is a dealbreaker and v8 isn't available in your language yet, use v4 and accept the page-split penalty. Or use ULIDs — which have the same timestamp-prefix design as v7 but in a 26-character Crockford base32 format, and are supported in every language through the ulid library ecosystem.
7. Migration Strategy: Coexistence Without Downtime
Migrating from UUID v4 to v7 is simpler than most database migrations because UUID v4 and v7 are the same size (128 bits), the same type (UUID in Postgres, CHAR(36) or BINARY(16) in MySQL), and the same format (8-4-4-4-12 hex with dashes). The version nibble is different — 4 vs 7 in position 13 — but that nibble is a structural marker, not a uniqueness constraint. A UUID column does not care which version its values are.
🟢 Migration Steps (Zero Downtime)
1. Update your UUID generation code. Point new inserts to a v7 generator. Existing rows keep their v4 values. No ALTER TABLE, no backfill, no trigger, no migration script.
2. Wait one full write cycle. Let the table accumulate v7 keys at the right edge of the index. The v4-scattered interior pages will not reorganize themselves. Only new writes — the ones landing on the rightmost leaf — benefit from temporal locality.
3. REINDEX CONCURRENTLY (Postgres). Rebuild the primary key index online. This physically re-sorts the pages, recovering the 30-40% bloat accumulated under v4.
-- Postgres: REINDEX INDEX CONCURRENTLY pk_your_table; -- MySQL/InnoDB (online on 8.0.28+): OPTIMIZE TABLE your_table;4. Validate. Run
SELECT pg_size_pretty(pg_relation_size('pk_your_table'));before and after. The index should shrink by 15-20%. Your INSERT throughput should increase by 30-40% on Postgres and 50-60% on InnoDB.5. No step 5. There is no backfill. There is no rollback script. UUID v4 and v7 coexist in the same column, use the same comparison operators, sort correctly, and require no application-level changes beyond the generation function. The migration is a library update. The index rebuild is the optimization, not the requirement.
If you cannot run REINDEX CONCURRENTLY, the v4→v7 switch will still produce a gradual improvement over time as old v4 pages age out of the buffer pool and new v7 pages dominate the write path. The page-split reduction applies to new writes regardless of what's already on disk. The index rebuild accelerates the benefit. It does not gate it.
When UUID v4 Still Wins
UUID v4 is the right choice when:
-
The UUID leaves your system. If the UUID appears in a public URL, an API response, or a client-side cookie, and the creation date of the identified resource is sensitive — use v4. v7's timestamp leakage is not theoretical. It is extractable with
parseInt(uuid.slice(0, 8), 16)in a browser console. - Write volume is under 500 INSERTs/second. At that scale, the B-tree page-split penalty is lost in the noise of network latency, connection pooling, and application logic. The extra 30% write throughput from v7 exists on a benchmark chart. It does not exist in your monitoring dashboard. Optimize when the bottleneck is visible.
- You are on UUID v4 now and cannot change. UUID v4 is fine. It has been fine since 1996. Billions of rows have been inserted into B-tree indexes indexed by v4 primary keys, and the databases have not melted. The page splits are real. They are also order-of-magnitude smaller than a missing index, a bad JOIN, or an N+1 query. If your database is slow, the primary key format is probably not why.
-
You need library support today, in every language. UUID v7 is supported in the
uuidnpm package (v9+, 2023), Python'suuid6(2021), Java'sjava-uuid-generator(2023), Go'sgithub.com/gofrs/uuid(v5+, 2024), and the Postgrespg_uuidv7extension. It is not yet in the standard library of most languages. By 2027 it probably will be. If your organization's policy is "stdlib only," you are on v4 until the stdlib catches up — and that's a reasonable position.
For everything else — every new project, every new table, every primary key where the UUID lives inside the database and not on a URL — use UUID v7. The collision resistance is still absurd (74 bits = 1.9 × 10²² values per millisecond). The write performance is 40% better on Postgres and 56% better on MySQL. The indexes are 18% smaller. The migration path from v4 is a library update and an optional REINDEX. And the timestamp prefix, which makes all of this possible, costs you nothing unless your IDs are public and your creation dates are private. For most internal database identifiers, that tradeoff is not a tradeoff. It's a free lunch.
🔑 The Verdict
UUID v4 was never bad at uniqueness. It was bad at locality. UUID v7 fixes locality without sacrificing uniqueness. The math — 122 random bits vs 74 random bits — is a red herring. The database cares about one thing: can it write to the same page as the previous INSERT? UUID v7 makes the answer yes. UUID v4 makes the answer "probably not." That's the whole article.
Further Reading
- UUID v4 Collision Probability at Scale — the companion article covering the birthday paradox math, RNG failure modes, and real production collision cases.
- RFC 9562: Universally Unique IDentifiers (UUID) — the May 2024 IETF standard that introduced UUID v6, v7, and v8. Replaces RFC 4122 (2005). Section 5.7 covers the v7 layout.
- PostgreSQL B-Tree Index Internals — the official documentation on fillfactor, page splits, and the HOT (Heap-Only Tuple) optimization that UUID v4's scattered writes defeat.
Benchmark methodology: The benchmarks in Section 4 use pgbench on Postgres 16 with shared_buffers=4GB, effective_cache_size=8GB, and a dedicated NVMe volume. MySQL benchmarks use sysbench on MySQL 8.0.36 with innodb_buffer_pool_size=4GB and innodb_flush_log_at_trx_commit=1. All tests insert 1M rows with 100 concurrent connections, a table schema of (id UUID PRIMARY KEY, payload TEXT, created_at TIMESTAMPTZ DEFAULT now()), and payload size averaging 256 bytes. UUID v4 generated via uuid-ossp, UUID v7 via pg_uuidv7. Your mileage will vary — benchmark your own workload.
This article was originally published on jslet, where we maintain 108 free developer tools and engineering calculators — all client-side, zero tracking. Companion tools: UUID Generator (v4 + v7) · UUID Collision Estimator · Unix Timestamp Converter.
Sources: IETF RFC 9562 (May 2024) · PostgreSQL 16 Documentation: B-Tree Index Internals · MySQL 8.0 Reference Manual: InnoDB Clustered Indexes · SQLite Documentation: B-Tree Module · NIST SP 800-90A Rev. 1 (2015)
Top comments (0)