UUIDv7 replaces the first 48 random bits of a 128-bit UUID with a big-endian Unix millisecond timestamp (unix_ts_ms), turning a scattered random identifier (UUIDv4) into a time-sortable key that appends cleanly to the right edge of a B-tree index. Standardized in IETF RFC 9562 Section 5.7 in May 2024 and now built into PostgreSQL 18 (uuidv7()) and Python 3.14 (uuid.uuid7()), UUIDv7 solves the B-tree page-split penalty of UUIDv4, but it also introduces timestamp privacy leakage and subtle clock-ordering boundaries that every backend engineer should weigh before switching.
What Is UUIDv7 and How Does Its 128-Bit Format Differ from UUIDv4?
Both UUIDv4 and UUIDv7 are 128-bit (16-byte) identifiers stored in the same uuid column type (BINARY(16) in MySQL, uuid in PostgreSQL) and formatted as 36-character hexadecimal strings (8-4-4-4-12). Any column, ORM field, or wire protocol that accepts a standard UUID accepts both versions without schema changes.
The difference lies entirely in how those 128 bits are assigned:
UUIDv4 (RFC 9562 §5.4) — 122 Random Bits, 0 Timestamp Bits
xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
└─ 48b rand ─┘ 4b└12b┘2b└── 62b rand ──┘
v4 var(10)
UUIDv7 (RFC 9562 §5.7) — 48-Bit Unix ms Timestamp + 74 Random/Counter Bits
0192be3d-ec90-71a4-8a3f-4c91b2e01101
└─ 48b unix_ts_ms ┘ 4b└12b┘2b└── 62b rand_b ──┘
v7 rand_a var(10)In a Version 4 UUID, every bit except the 4-bit version (0100, hex 4) and the 2-bit variant (10, hex 8, 9, a, or b) comes from a cryptographically secure random number generator (CSPRNG).
In a Version 7 UUID, the 128 bits are divided into five fields:
- Bits 0 to 47 (
unix_ts_ms): Unsigned 48-bit big-endian integer counting milliseconds since the Unix epoch (1970-01-01T00:00:00Z). Because it sits in the most significant bytes, standard byte-wise and ASCII string sorting orders UUIDv7 values chronologically until the year 10889. - Bits 48 to 51 (
ver): Four bits fixed to0111(hex7at character position 15). - Bits 52 to 63 (
rand_a): Twelve bits (0to4,095) filled either with CSPRNG random bits or with a sub-millisecond monotonic counter (RFC 9562 Section 6.2). - Bits 64 to 65 (
var): Two bits fixed to10(the first character of the fourth group is8,9,a, orb). - Bits 66 to 127 (
rand_b): Sixty-two bits of cryptographic randomness (crypto.getRandomValues()in browsers or OS/dev/urandom).
You can generate time-ordered batches and compare rand_a monotonic counter behavior against pure random mode directly in the UUIDv7 Generator.
Side-by-Side Comparison: UUIDv7 vs. UUIDv4
| Dimension | UUIDv4 (Random) | UUIDv7 (Time-Ordered) |
|---|---|---|
| RFC Standard | RFC 9562 §5.4 (formerly RFC 4122) | RFC 9562 §5.7 (May 2024) |
| Timestamp Payload | None (0 bits) | 48-bit Unix epoch milliseconds (unix_ts_ms) |
| Random / Counter Bits | 122 bits CSPRNG | 74 bits total (12-bit rand_a + 62-bit rand_b) |
| Version Character (Pos 15) | 4 | 7 |
| B-Tree Insert Pattern | Random across entire index range (frequent 50/50 page splits) | Append-mostly at right edge of index (sequential leaf fills) |
| Reveals Creation Time? | No (completely opaque) | Yes (exact millisecond is readable from first 12 hex chars) |
| Browser Web Crypto API | Built-in via crypto.randomUUID() | Requires 16-byte buffer assembly (Date.now() + getRandomValues) |
| PostgreSQL 18 Native Function | gen_random_uuid() / uuidv4() | uuidv7() and uuid_extract_timestamp() |
| Python 3.14 Standard Library | uuid.uuid4() | uuid.uuid7() |
Why B-Tree Indexes Prefer UUIDv7 Once Tables Outgrow RAM
On a small table of 100,000 rows, the entire primary key B-tree index fits inside a few megabytes of RAM (shared_buffers in PostgreSQL or innodb_buffer_pool_size in MySQL). At that scale, random inserts into a UUIDv4 index touch in-memory pages and you will barely notice a difference.
The penalty shows up when your index grows larger than available memory. Because every new UUIDv4 is uniformly distributed across the full $2^{122}$ keyspace, each INSERT targets a random leaf page on disk:
- The database must read a cold 8 KB or 16 KB index leaf page from disk into the buffer pool.
- Once that leaf page fills up, inserting a key in the middle forces a 50/50 page split, leaving two half-empty pages and increasing index bloat.
- Under high write concurrency, dirty random leaf pages must be flushed back to disk continuously (write amplification).
Because every new UUIDv7 starts with the current millisecond timestamp, all new inserts land on the rightmost leaf page of the B-tree index. Only the active tail of the index needs to stay hot in memory, matching the cache locality of a BIGSERIAL or IDENTITY key while still allowing application nodes to mint IDs independently.
Three Ordering and Timestamp Misconceptions to Avoid
Recent discussions around UUIDv7 frequently conflate "time-sortable for B-tree locality" with "strict global event ordering" or "tamper-proof creation timestamps." Those are three very different guarantees.
1. Same-Millisecond Ordering Depends on How rand_a Is Implemented
The unix_ts_ms field has a resolution of 1 millisecond. If a single worker thread inserts 50 rows inside the same millisecond, all 50 UUIDs share the exact same 12-hex-digit prefix.
Whether those 50 UUIDs sort in creation order depends on which optional monotonic scheme from RFC 9562 Section 6.2 your library implements:
- Pure 74-bit random (no counter):
rand_aandrand_bare filled with fresh random bits on every call. IDs minted in different milliseconds sort in order, but IDs minted within the same millisecond sort randomly. - Monotonic counter in
rand_a(Method 1 / Method 2): When the millisecond timestamp matches the previous call, the generator incrementsrand_aby+1(up to4,095IDs per millisecond). This guarantees ascending sort order within that single process or browser session, but not across two separate servers generating IDs in the same millisecond.
Notice also that browser JavaScript's built-in crypto.randomUUID() only generates Version 4 UUIDs. Never alias crypto.randomUUID() as a v7 generator.
2. An Embedded Timestamp Is Not an Audited Database created_at
In PostgreSQL 18, you can call uuid_extract_timestamp(id) on a UUIDv7 column to read back the 48-bit timestamp as a timestamptz. That is handy for ad-hoc debugging or partitioning, but the PostgreSQL documentation explicitly cautions that an extracted timestamp only reflects the clock of the system that generated the UUID.
Consider three common production scenarios where a UUIDv7 timestamp diverges from actual row creation:
- Client-generated IDs (offline-first mobile or web apps): A phone whose clock is 10 minutes fast (or deliberately spoofed by a user) sends a payload with a client-minted UUIDv7.
- Pre-allocated ID batches or long transactions: An application worker mints a UUIDv7 at the start of an HTTP request, waits 800 ms for an external payment webhook, and commits the database transaction after a retry.
- NTP clock step-backs: If a host clock steps backward by 50 ms during an NTP adjustment, a monotonic generator may freeze the timestamp at the last seen millisecond and increment the counter, shifting the apparent timestamp slightly ahead of wall-clock time.
If you need to inspect what timestamp is encoded inside an existing UUID, check whether a batch is strictly ordered, or verify that a v4 UUID contains no hidden time bits, paste your IDs into the UUID Validator & Decoder.
3. Why "UUIDv4 to UUIDv7 Converters" Are Impossible
A surprisingly common search query is how to convert an existing UUIDv4 into a UUIDv7. Mathematically, that conversion does not exist: a UUIDv4 has 122 random bits and zero timestamp bits. The moment a UUIDv4 was generated, the creation time was never recorded in the ID.
If you rewrite the 15th character of f47ac10b-58cc-4372-a567-0e02b2c3d479 from 4 to 7, a decoder will read the first 12 hex characters (f47ac10b58cc, which is 268,808,062,888,140 ms) as the year 10488. The only valid way to backfill UUIDv7 keys for historical rows is to read each row's stored created_at column and generate a new UUIDv7 using that timestamp.
When You Should Still Choose UUIDv4 Instead of UUIDv7
My practical rule for schema design is straightforward:
- Use UUIDv7 for internal database primary keys, high-volume event tables, audit logs, and foreign keys when your tables will grow past a few million rows or when roughly chronological clustering simplifies pagination.
- Keep using UUIDv4 whenever the identifier is exposed directly in public URLs, unauthenticated APIs, share links, or customer-facing resource IDs where creation time is sensitive.
Because the first 12 hex characters of a UUIDv7 are a plain Unix millisecond integer, anyone who sees /org/0192be3d-ec90-71a4-8a3f-4c91b2e01101 knows the exact millisecond that organization signed up. Worse, if two sequential objects are created within the same millisecond using a +1 monotonic counter in rand_a, an observer can see how many records were minted between two requests. If you want UUIDv7 index locality in the database *and* opaque public URLs, either store UUIDv7 as the internal primary key with a separate random public slug / UUIDv4 column, or encrypt the external representation.
When you need purely random, zero-timestamp identifiers for public endpoints, correlation headers, or test fixtures, use the Version 4 UUID Generator.