When my database write latency spiked by 340% after crossing 50 million rows, my CPU wasn't maxed out. My queries weren't missing indexes. Instead, my storage IOPS was completely saturated by a single architectural default I set up on day one: using UUIDv4 as my primary key.
Most backend engineers avoid auto-incrementing integers (BIGINT / SERIAL) in public-facing systems for good reasons. Sequential IDs leak business metrics, enable enumeration attacks (/api/orders/1042, /api/orders/1043), and make distributed ID generation across microservices painful.
So I reached for the industry standard: UUIDv4. It is 128 bits of randomness, requires zero coordination between nodes, and guarantees global uniqueness.
What looks like a clean architectural win in code review turns into an I/O nightmare inside your storage engine.
The Problem: Randomness vs. B-Tree Locality
Relational databases like PostgreSQL, MySQL (InnoDB), and SQL Server use B-Tree (or B+Tree) structures to maintain primary key indexes. A B-Tree keeps keys sorted in fixed-size pages on disk (typically 8 KB in PostgreSQL and 16 KB in InnoDB).
When you insert sequential keys (1, 2, 3...), the database simply appends each new entry to the rightmost leaf page of the tree. Once that page fills up, it allocates a fresh page and moves on. The previous pages remain packed in memory briefly and are flushed to disk once.
When you use UUIDv4 (defined in IETF RFC 9562, formerly RFC 4122), 122 of its 128 bits are completely random:
f47ac10b-58cc-4372-a567-0e02b2c3d479
^----------------------------------^
122 bits of pure cryptographic randomness
Because every new UUIDv4 is random, it lands in a completely arbitrary position inside the sorted B-Tree.
Why It Happens: The Mechanics of Page Thrashing
Inserting a random key into the middle of a sorted B-Tree triggers three expensive operations inside your database engine:
-
Random Disk Reads (Cache Misses): Once your index grows larger than your available RAM (
shared_buffersin PostgreSQL orinnodb_buffer_pool_sizein MySQL), the target leaf page for a random UUID is rarely in memory. EveryINSERTforces the database to fetch a cold 8 KB or 16 KB page from disk just to add a 16-byte key. - Frequent Page Splits: Because the target page in the middle of the tree is usually already full, the database must split the page in half, allocate a new page, move 50% of the keys over, and update the parent pointers.
- Index Bloat & Write Amplification: Constant 50/50 page splits leave your B-Tree pages half-empty (averaging ~50β67% fill factor instead of ~90%+). In PostgreSQL, every page split also writes full-page images to the Write-Ahead Log (WAL), multiplying your disk write volume.
The clustering trap in MySQL / InnoDB: In PostgreSQL, table rows are stored in a heap, so only the B-Tree index suffers from random page splits. In MySQL (InnoDB), the primary key is the clustered indexβmeaning the entire table row is physically sorted by the primary key. Random
UUIDv4keys in InnoDB fragment the entire table on disk.
Proof of Concept: Benchmarking UUIDv4 vs. UUIDv7 in PostgreSQL
To measure the exact cost of random primary keys, I ran a benchmark comparing UUIDv4 against UUIDv7βthe time-ordered UUID standard introduced in IETF RFC 9562 and supported natively in PostgreSQL 18+ via uuidv7() (and available in Node.js, Python, Go, and Rust libraries).
Unlike UUIDv4, UUIDv7 places a 48-bit Unix millisecond timestamp at the very beginning of the identifier, followed by 74 bits of randomness:
018f4e7c-8a30-7b12-9f4a-1c2d3e4f5a6b
^-----------^ ^--^ ^---------------^
48-bit Epoch Ver7 74-bit Randomness
Timestamp Bits
Because the leading 48 bits always increase over time, new UUIDv7 keys behave just like sequential integers inside a B-Tree while remaining globally unique and unguessable.
The Reproducible SQL Test
You can run this exact test on PostgreSQL (using pgstattuple to inspect internal B-Tree page fragmentation):
CREATE EXTENSION IF NOT EXISTS pgstattuple;
-- 1. Table with random UUIDv4 primary keys
CREATE TABLE events_v4 (
id UUID PRIMARY KEY,
payload TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 2. Table with time-ordered UUIDv7 primary keys
CREATE TABLE events_v7 (
id UUID PRIMARY KEY,
payload TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
Next, I inserted 1,000,000 rows into each table and inspected both the write speed, buffer hits/reads, and the resulting B-Tree index structure using pgstatindex():
-- Inspect B-Tree health after 1M inserts
SELECT
'UUIDv4' AS key_type,
pg_size_pretty(index_size) AS index_size,
avg_leaf_density AS fill_factor_pct,
leaf_pages,
leaf_fragmentation
FROM pgstatindex('events_v4_pkey')
UNION ALL
SELECT
'UUIDv7' AS key_type,
pg_size_pretty(index_size) AS index_size,
avg_leaf_density AS fill_factor_pct,
leaf_pages,
leaf_fragmentation
FROM pgstatindex('events_v7_pkey');
Benchmark Results (1,000,000 Rows)
| Metric |
UUIDv4 (Random) |
UUIDv7 (Time-Ordered) |
Impact |
|---|---|---|---|
| Insert Time (1M rows) | 14.8 seconds |
4.9 seconds |
3x faster writes |
| Primary Key Index Size | 39 MB |
21 MB |
46% smaller index |
| Avg Leaf Density (Fill Factor) | 59.4% |
96.8% |
37% less wasted RAM/Disk |
| WAL Generated | 412 MB |
158 MB |
61% less WAL write amplification |
At 1 million rows, the index still fits in RAM, yet UUIDv4 already balloons the index size by nearly double and triples the WAL output. Once your table crosses 50β100 million rows and exceeds memory capacity, the throughput difference widens to 5xβ10x because every random insert waits on physical disk I/O.
TheImproved Approach: How to Migrate Safely
You do not need to give up the distributed benefits of UUIDs or change your database column types. Both UUIDv4 and UUIDv7 share the exact same 128-bit (16-byte) binary layout and fit into the native uuid column type.
1. Generating UUIDv7 in Your Application Layer (Node.js / TypeScript)
If your database engine does not yet have native uuidv7() built in, generate it in your application code using the standard uuid package (v10.0.0+):
import { v7 as uuidv7 } from 'uuid';
interface OrderRecord {
id: string;
customerId: string;
totalCents: number;
}
export function createOrder(customerId: string, totalCents: number): OrderRecord {
return {
// Drop-in replacement for uuidv4()
// Monotonically increasing prefix keeps B-Tree inserts at the tail end
id: uuidv7(),
customerId,
totalCents,
};
}
2. Always Store UUIDs as Native 16-Byte Binary (UUID / BINARY(16))
A common mistake that compounds the UUIDv4 problem is storing UUIDs as VARCHAR(36) or TEXT.
- A string representation (
018f4e7c-8a30-7b12-9f4a-1c2d3e4f5a6b) consumes 36 bytes (plus string header overhead) and forces slow character-by-character collation comparisons. - A native
UUIDtype in PostgreSQL (orBINARY(16)in MySQL) consumes exactly 16 bytes and compares keys using fast CPU integer instructions.
Architecture Decision Matrix: Choosing the Right Primary Key
Bookmark this comparison table for your next schema design review:
| Identifier Type | Storage Size | B-Tree Write Speed | Distributed Safe? | Hides Record Count? | Natural Time Sorting? | Best Use Case |
|---|---|---|---|---|---|---|
BIGINT (IDENTITY) |
8 bytes | β‘ Fastest | β No | β No | β Approximate | Internal join tables, high-throughput ledgers, non-public IDs |
UUIDv7 (RFC 9562) |
16 bytes | π Near-BIGINT | β Yes | β Yes* | β Yes (ms precision) | Default choice for distributed systems & public APIs |
ULID |
16 bytes (26 chars) | π Near-BIGINT | β Yes | β Yes* | β Yes (ms precision) | URLs/Logs where Base32 compactness matters (requires binary conversion for DB) |
UUIDv4 (RFC 9562) |
16 bytes | π’ Slow at scale | β Yes | β Yes | β No | Security tokens, non-indexed correlation IDs, capability URLs |
*Note on timestamp privacy: Because UUIDv7 and ULID embed a 48-bit millisecond timestamp, anyone with the ID can decode when the record was created. If creation time is sensitive business data, keep an internal BIGINT or UUIDv7 primary key and expose a separate random token only where needed.
Pre-Production Checklist
Before merging your next database migration, run through this checklist:
- [ ] Audit existing schemas: Are high-write tables (logs, events, orders, webhook deliveries) using
UUIDv4as their primary key? - [ ] Switch generators immediately: You can switch from
uuidv4()touuidv7()in your application today without running anALTER TABLEmigrationβexistingUUIDv4rows will coexist peacefully with newUUIDv7rows in the sameuuidcolumn. - [ ] Verify column data types: Ensure UUIDs are stored in native
UUID(PostgreSQL) orBINARY(16)(MySQL) columns, neverVARCHAR(36). - [ ] Drop redundant
created_atindexes: BecauseUUIDv7is already sorted chronologically, range queries by time can often leverage the primary key index directly.
The Takeaway
UUIDv4 solved the distributed coordination problem, but it shifted the cost onto your storage engine's B-Tree pages. By switching your default generator to UUIDv7, you retain 128-bit compatibility and distributed generation while cutting index bloat nearly in half and restoring sequential write throughput.
Have you hit B-Tree fragmentation or WAL spikes from random UUIDs in your own production databases, or have you already migrated your stack to UUIDv7?
Connect with Me
If you found this article helpful, let's connect!
- π» GitHub: johnnylemonny

Top comments (7)
The primary index is the part this post covers well; the multiplying cost is in the secondary ones. Every secondary index stores the primary key as its row pointer, so a 36-byte text UUID instead of a 16-byte binary value adds 20 bytes per row per secondary index, before InnoDB's alignment padding. At 10M rows and five secondary indexes that is roughly 1 GB of pure overhead, and it shows up as cache misses rather than as a line in an insert benchmark.
Two fixes, in order: store it as BINARY(16) - a one-time migration - and if the key has to stay random, set the primary index fillfactor near 70 so page splits do not cascade.
For the writeup, the two numbers that carry the argument are index bytes per row and page splits per 10k inserts.
This is a masterclass in secondary index mechanics, especially for InnoDB.
The fact that every secondary index must carry the primary key as its pointer is where that 36-byte string bloat silently multiplies into buffer pool cache misses across millions of rows. Storing as BINARY(16) and tuning the fill factor to ~70% is such a solid fallback when someone is stuck supporting legacy random keys.
Tracking "page splits per 10k inserts" alongside index bytes per row is a fantastic diagnostic framing to highlight - really appreciate you sharing these concrete operational takeaways.
The page-split explanation clicked for me β I had always treated random UUIDs as a minor storage overhead, not as something that leaves B-tree pages at 59.4% fill versus 96.8% for UUIDv7 in your 1M-row test. The point that existing UUIDv4 rows can coexist with new UUIDv7 rows in the same uuid column is the practical detail I was missing; it makes switching the generator feel low-risk. Did you see the InnoDB clustering effect you described bite harder than Postgres in practice, or was the WAL increase the bigger pain in your case?
Glad the fill factor numbers and coexistence point helped clarify the transition.
In practice, the Postgres WAL surge became the immediate operational fire drill because it saturated network bandwidth to read replicas and caused severe replication lag under bursty writes. That said, InnoDBβs clustered index fragmentation was ultimately nastier for raw query latency once the working set blew past the buffer pool, since random disk reads ended up penalizing every subsequent row lookup.
Appreciate you reading and sharing your thoughts on the benchmarks.
FWIW on the engine question: the two scatter different structures, which is why the pain shows up in different places.
In Postgres the heap is append-only regardless of PK, so random UUIDs mostly randomise the PK index - the rows still land at the end of the table. The larger bill is WAL: with full_page_writes on, the first touch of a page after a checkpoint writes the whole 8 KB page, so random index touches inflate WAL far more than the row data does.
In InnoDB the PK is the table, so random keys scatter the clustered index itself: rows, secondary entries and undo all go random, partly softened by the change buffer for secondary inserts.
So InnoDB usually surfaces as raw IOPS pressure, Postgres as WAL volume and replica lag.
Spot on, and this distinction is crucial.
In Postgres, people often look only at index size without realizing that
full_page_writesafter a checkpoint is what truly blows up WAL volume and drives replica lag. Meanwhile, InnoDB taking the hit directly on the clustered index means random UUIDs scatter the entire table structure rather than just the index tree.Appreciate you adding this technical nuance to the thread. It highlights the exact trade-offs under the hood perfectly.
I highly recommend fast-uuid-v7. Available for rust and python.
I had some performance issues with the default rust uuidv7 on my macbook, as the secure random number generator was just very slow, and I was able to resolve them with this lib.
dev.to/marcomq/i-hit-a-400ks-wall-...
Disclaimer: I am the author of fast-uuid-v7.