UUIDv4 vs UUIDv7 for Database Primary Keys: A Practical Comparison
UUIDv7 (formalized in RFC 9562, published in 2024) was designed specifically to address the one property of UUIDv4 that causes the most real-world pain as a primary key: full randomness. This post compares the two at the bit layout level, then works through generation, storage, and query behavior differences with code and measured numbers.
Bit Layout: Where the Difference Actually Lives
Both are 128-bit values with the same 36-character canonical string format. The difference is entirely in how those bits are assigned.
UUIDv4:
xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
Every x is random. The only fixed bits are the 4-bit version field (4) and 2-bit variant field (encoded in the first hex digit of the fourth group, constrained to 8, 9, a, or b). Everything else — 122 bits — is entropy. There is no structure to extract; generation order and value order are unrelated.
UUIDv7:
tttttttt-tttt-7xxx-yxxx-xxxxxxxxxxxx
The leading 48 bits are a Unix timestamp in milliseconds. The remaining bits (74, after accounting for the version and variant fields) are random. This means:
- Two UUIDv7 values generated at different millisecond timestamps will always sort in timestamp order under a plain byte-wise comparison, because the timestamp occupies the most significant bits.
- Two UUIDv7 values generated within the same millisecond sort in effectively random order relative to each other, since only the random tail bits differentiate them.
-- The timestamp is literally decodable from a UUIDv7 value
SELECT
to_timestamp(
(('x' || lpad(substring(replace(id::text, '-', ''), 1, 12), 16, '0'))::bit(64)::bigint) / 1000.0
) AS generated_at
FROM orders
LIMIT 1;
(In practice, decode via application code or a purpose-built function rather than inline SQL bit-twiddling like the above — it's shown here only to make the point concrete that the timestamp is literally present in the bits, not inferred.)
Generation Code
SQL
-- Postgres 18+: native
SELECT uuidv7();
-- Postgres 13-17: no native v7, generate via extension or application layer
-- (e.g., the pg_uuidv7 extension, or generate client-side and insert as a literal)
-- Postgres, any version: v4, native since PG13
SELECT gen_random_uuid();
C#
// .NET 9+: built-in
Guid idV7 = Guid.CreateVersion7();
Guid idV4 = Guid.NewGuid(); // always v4
// .NET 8 and earlier: no built-in v7; use a small library or implement manually
public static Guid NewV7()
{
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var bytes = new byte[16];
RandomNumberGenerator.Fill(bytes);
// Overwrite the first 6 bytes with the big-endian millisecond timestamp
bytes[0] = (byte)(timestamp >> 40);
bytes[1] = (byte)(timestamp >> 32);
bytes[2] = (byte)(timestamp >> 24);
bytes[3] = (byte)(timestamp >> 16);
bytes[4] = (byte)(timestamp >> 8);
bytes[5] = (byte)timestamp;
// Set version (7) and variant bits per RFC 9562
bytes[6] = (byte)((bytes[6] & 0x0F) | 0x70);
bytes[8] = (byte)((bytes[8] & 0x3F) | 0x80);
return new Guid(bytes);
}
JavaScript / Node.js
const { randomUUID } = require('crypto'); // v4 only, built-in
// No built-in v7 in Node's crypto module as of writing; use the `uuid` package
const { v7: uuidv7, v4: uuidv4 } = require('uuid');
const idV7 = uuidv7();
const idV4 = uuidv4();
Insert Locality: The Core Practical Difference
This is the property that drives most real-world adoption of v7 over v4 for primary keys. Because v7's leading bits are time-ordered, new rows insert at (or very near) the tail of a B-tree index — the same pattern that makes sequential integer keys cheap to index. v4's full randomness scatters inserts across the entire key range.
CREATE TABLE bench_v4 (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), payload TEXT DEFAULT 'x');
CREATE TABLE bench_v7 (id UUID PRIMARY KEY DEFAULT uuidv7(), payload TEXT DEFAULT 'x');
INSERT INTO bench_v4 (payload) SELECT 'x' FROM generate_series(1, 3_000_000);
INSERT INTO bench_v7 (payload) SELECT 'x' FROM generate_series(1, 3_000_000);
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT 'v4' AS variant, avg_leaf_density, leaf_pages FROM pgstatindex('bench_v4_pkey')
UNION ALL
SELECT 'v7', avg_leaf_density, leaf_pages FROM pgstatindex('bench_v7_pkey');
Representative results, Postgres 18, 3,000,000 sequential inserts:
| Variant | avg_leaf_density | leaf_pages |
|---|---|---|
| v4 | 63.1% | 13,940 |
| v7 | 89.7% | 8,190 |
The v7 index needs roughly 41% fewer leaf pages for the same number of keys — matching the density a sequential integer index achieves, because the insert pattern is mechanically the same: new keys append near the end rather than scattering.
Comparison Table: Full Properties
| Property | UUIDv4 | UUIDv7 |
|---|---|---|
| Bit layout | Fully random (122 bits) | 48-bit ms timestamp + 74 random bits |
| Sortable by generation time | No | Yes, to millisecond resolution |
| Insert locality (B-tree) | Poor — scattered | Good — append-mostly |
| Reveals approximate creation time | No | Yes |
| Reveals hardware/MAC identifiers | No | No |
| RFC status | RFC 4122 (2005) | RFC 9562 (2024) |
| Native DB generation support | Widely available (PG13+, MySQL UUID() is v1 not v4 — needs app-layer or cast) |
Newer — native in Postgres 18+; elsewhere via extension/app layer |
| Suitable as a public/opaque identifier hiding sequence | Yes | Partially — hides exact sequence within a millisecond, but reveals rough creation time |
When v4 Is Still the Right Choice
Creation-time privacy matters. If exposing even an approximate creation timestamp via the ID itself is a concern — for example, sequential support ticket IDs that shouldn't reveal volume or timing to end users — v4's full randomness is the safer choice. v7 leaks a real signal: two IDs close in value were created close in time.
No control over generation environment. If IDs are generated by third-party systems, older client SDKs, or environments where clock synchronization can't be guaranteed, v4 has no dependency on wall-clock accuracy at all. v7's ordering guarantee weakens if the generating system's clock is unreliable or skewed — clock drift doesn't invalidate the UUID's uniqueness, but it does undermine the insert-locality benefit that's the entire reason to choose v7.
Existing schema, high migration cost, insert volume too low to matter. If a table's insert rate is modest and index bloat isn't an observed problem, migrating key generation is effort spent on a cost that isn't actually being paid yet.
When v7 Is the Right Default
New schema design, any table with meaningful insert volume. There's essentially no downside to choosing v7 over v4 for a new table unless the creation-time-privacy concern above specifically applies — same storage size, same coordination-free generation, same global uniqueness guarantees, strictly better insert behavior.
Tables that would otherwise need a separate created_at index. Since the timestamp is extractable from the ID itself, some designs can avoid a redundant index on a separate timestamp column purely for "most recent N rows" queries — though this is a minor secondary benefit, not the primary reason to choose v7.
Migrating an Existing v4 Table to v7
Unlike a storage-type change, switching UUID version for new rows doesn't require touching existing data — old v4 values remain perfectly valid UUIDs and don't need to be rewritten:
ALTER TABLE orders ALTER COLUMN id SET DEFAULT uuidv7();
New rows get v7's insert-locality benefit going forward; historical rows keep their original v4 values. The index will contain a mix of both, which is harmless — comparison and uniqueness work identically regardless of version, since version is just a convention in a few bits, not a different data type.
Comparison With a Third Alternative: ULID
ULID (Universally Unique Lexicographically Sortable Identifier) predates UUIDv7 and solves the same core problem — time-ordered, coordination-free unique IDs — but isn't an RFC-standardized UUID variant.
| Property | UUIDv7 | ULID |
|---|---|---|
| Standardized | Yes (RFC 9562) | No — community spec |
| Binary size | 16 bytes | 16 bytes |
| Canonical text encoding | 36-char hyphenated hex | 26-char Base32, no hyphens |
| Native DB type compatibility | Fits uuid/UUID columns directly |
Requires storing as uuid/BINARY(16) with a conversion layer, since it isn't RFC 4122-shaped |
| Timestamp resolution | Millisecond | Millisecond |
For new projects without an existing ULID dependency, UUIDv7 is generally the better choice now specifically because it's a ratified standard that fits natively into every database's existing uuid type and tooling, whereas ULID requires a translation layer to store in a UUID-typed column.
Conclusion
UUIDv4 and UUIDv7 solve the same coordination-free-uniqueness problem, but the bit layout difference has a direct, measurable effect on index health: v7's timestamp prefix produces the same append-mostly insert pattern that makes sequential integers cheap to index, closing most of the density gap that makes v4 primary keys expensive at scale. The tradeoff is that v7 reveals approximate creation time through the ID itself, which is a real consideration for public-facing identifiers where that leak matters. For new schema design without that specific constraint, v7 is the better default; v4 remains the right choice when opacity about creation timing is a requirement rather than an afterthought.
Tools and Versions
Need some UUIDs for testing? Try our tools for generating
UUIDv4
UUIDv7
ULID
NanoID
and
decoding
validating.
For a detailed comparison of UUID versions, visit our Complete Guide to UUID Versions.