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 (0)