UUIDv4 Is Not Free: Why Random Primary Keys Are Silently Crushing Your Write Performance
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_buffers in PostgreSQL or innodb_buffer_pool_size in MySQL), the target leaf page for a random UUID is rarely in memory. Every INSERT forces 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 UUIDv4 keys 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 UUID type in PostgreSQL (or BINARY(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 UUIDv4 as their primary key? [ ] Switch generators immediately: You can switch from uuidv4() to uuidv7() in your application today without running an ALTER TABLE migration—existing UUIDv4 rows will coexist peacefully with new UUIDv7 rows in the same uuid column. [ ] Verify column data types: Ensure UUIDs are stored in native UUID (PostgreSQL) or BINARY(16) (MySQL) columns, never VARCHAR(36). [ ] Drop redundant created_at indexes: Because UUIDv7 is 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 𝗝𝗼𝗵𝗻Follow Freelance fullstack developer creating fast, modern, and refined applications. I focus on simplicity, performance, and real value for users. I also write tech-related articles here.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to