All posts
Software

UUIDv7 vs UUIDv4 for Database Primary Keys: How Time-Ordered IDs Work

Random UUIDv4 keys scatter inserts across a B-tree index, while UUIDv7 puts a millisecond timestamp first so new rows land together. This guide covers the RFC 9562 bit layout, the write-path cost of randomness, and when each version is the right choice.

LeepCast7 min read
UUIDv7 vs UUIDv4 for Database Primary Keys: How Time-Ordered IDs Work

UUIDs are a popular choice for primary keys because any service can mint one without asking a database for the next number. That independence makes them easy to use in distributed systems, offline clients, and data imports. The catch is that the most widely used flavor, version 4, is pure randomness, and databases that store keys in sorted indexes pay for that randomness on every insert.

RFC 9562, published in May 2024, replaced the original UUID specification, RFC 4122, and added three new versions. One of them, version 7, was designed with database keys in mind. This explainer covers how UUIDv7 is laid out, why its ordering matters for a B-tree index, what it gives away in exchange, and how to generate one in PostgreSQL and Python today.

A quick map of the UUID versions

Every UUID is 128 bits, and a few of those bits identify the version and variant. The rest are filled in differently depending on the version. RFC 9562 defines the following:

  • Version 1: a Gregorian-calendar timestamp plus a node identifier, historically the machine's MAC address.
  • Versions 3 and 5: name-based, hashing a namespace and a name with MD5 or SHA-1, so the same input always yields the same UUID.
  • Version 4: 122 random or pseudorandom bits. This is what most libraries and the PostgreSQL function gen_random_uuid() produce.
  • Version 6: version 1 with its timestamp fields reordered so the values sort by time. The RFC advises that systems without legacy version 1 needs should use version 7 instead.
  • Version 7: a Unix-epoch timestamp in milliseconds, followed by random bits.
  • Version 8: a container for custom or vendor-specific formats, where only the version and variant bits are fixed.

The specification also names two special values: the Nil UUID, with all 128 bits set to zero, and the Max UUID, with all 128 bits set to one. Both are useful as sentinels, for example to mean no value or the end of a range.

How UUIDv7 is laid out

The structure of a version 7 UUID is short enough to remember. Reading the 128 bits from the most significant end:

  1. unix_ts_ms, 48 bits: milliseconds since 1970-01-01 UTC, stored big-endian.
  2. ver, 4 bits: the value 0111, which is why the first digit of the third group of a printed UUIDv7 is always 7.
  3. rand_a, 12 bits: random data, or optionally extra clock precision or a counter.
  4. var, 2 bits: the value 10, marking the RFC variant.
  5. rand_b, 62 bits: random data, part of which may also hold a counter.

Because the timestamp occupies the most significant bits, two UUIDv7 values created in different milliseconds compare in creation order, whether they are compared as raw bytes or as their usual hex strings. The RFC states this goal directly: version 6 and version 7 values are meant to be sortable as opaque bytes, with no parsing required. Within a single millisecond the ordering depends on what fills the remaining bits, which is the subject of a later section.

Why random keys are hard on a B-tree

Most relational databases keep a primary key in a B-tree index, a structure of fixed-size pages whose leaf pages hold keys in sorted order. Where a new key goes depends entirely on its value. A sequential key, such as an auto-incrementing integer, is always the largest value so far, so every insert lands on the rightmost leaf page. That page stays in memory, fills up, and the tree simply grows to the right.

A random UUIDv4 has no relationship to the keys inserted just before it, so each insert is likely to touch a different leaf page somewhere in the index. Once the index is larger than the available cache, many of those pages have to be read from disk before they can be modified. Pages that fill up split, and because the splits happen all over the tree rather than only at the right edge, pages tend to be left partially empty, so the index occupies more space than the same keys would need if they arrived in order.

PostgreSQL adds one more cost. With the full_page_writes setting enabled, which is the default, the server writes the entire contents of a page to the write-ahead log the first time that page is modified after a checkpoint. An insert pattern that touches a few pages repeatedly produces few of these full-page images. An insert pattern that touches a different page almost every time produces many of them. A long-running discussion on the PostgreSQL hackers mailing list traces exactly this effect to the randomness of UUID keys: each insert touches a different, usually already existing, leaf page.

The write cost of a key is decided less by its size than by how many distinct index pages a burst of inserts touches.

— LeepCast summary of the PostgreSQL full_page_writes behavior

UUIDv7 restores most of the sequential pattern. Keys generated close together in time share a timestamp prefix and therefore sit next to each other in the index, so recent inserts concentrate on a small set of pages near the right edge, much as an integer sequence would. The exact gain depends on table size, cache size, and write rate, so it is worth measuring against a real workload rather than assuming a figure.

Ordering inside a single millisecond

A busy service can create many IDs within the same millisecond. If the remaining 74 bits are purely random, those IDs are unique with very high probability but are not in creation order relative to each other. For an index that is usually fine, because they still land in the same small region. When strict ordering matters, for instance when IDs double as a cursor for pagination, RFC 9562 describes three ways a generator can stay monotonic:

  • Method 1, a fixed-length counter placed right after the timestamp, which the RFC suggests should be between 12 and 42 bits long.
  • Method 2, monotonic random, where the random portion acts as a randomly seeded counter that is increased by a random amount for each new ID.
  • Method 3, increased clock precision, where up to 12 bits of rand_a hold a sub-millisecond fraction of the clock.

Real implementations pick different methods. The PostgreSQL documentation describes its uuidv7() as a millisecond Unix timestamp plus a sub-millisecond timestamp plus random bits. The Python documentation states that uuid. uuid7() embeds a 48-bit timestamp and uses a 42-bit counter to guarantee monotonicity within a millisecond. These guarantees apply to a single generator; IDs created on different machines are only as well ordered as those machines' clocks.

Generating UUIDv7 in PostgreSQL and Python

PostgreSQL 18, released in September 2025, added a built-in uuidv7() function and an explicit uuidv4() alias alongside the long-standing gen_random_uuid(). The documentation also lists uuid_extract_timestamp(), which returns the embedded time of a version 1 or version 7 UUID, and uuid_extract_version(). A table can use the new function as its key default:

sql
CREATE TABLE orders (
    id         uuid PRIMARY KEY DEFAULT uuidv7(),
    customer   text NOT NULL,
    total_cents integer NOT NULL
);

INSERT INTO orders (customer, total_cents) VALUES ('acme', 4200);

SELECT id,
       uuid_extract_version(id)   AS version,
       uuid_extract_timestamp(id) AS created_at
FROM orders;

On the application side, Python 3.14 added uuid. uuid7() to the standard library, along with uuid6(), uuid8(), and the NIL and MAX constants. For older Python versions, a basic generator that follows the RFC layout fits in a few lines. The version below is correct but does not add a counter, so IDs created in the same millisecond are unique but not ordered among themselves:

python
import os
import time
import uuid


def uuid7_simple() -> uuid.UUID:
    """UUIDv7 per RFC 9562 section 5.7: 48-bit ms timestamp, then random bits."""
    ts_ms = time.time_ns() // 1_000_000
    rand = int.from_bytes(os.urandom(10), "big")  # 80 random bits, 74 are used
    value = (ts_ms & ((1 << 48) - 1)) << 80        # unix_ts_ms in bits 0-47
    value |= 0x7 << 76                             # ver = 0b0111
    value |= ((rand >> 62) & 0xFFF) << 64          # rand_a: 12 bits
    value |= 0b10 << 62                            # var = 0b10
    value |= rand & ((1 << 62) - 1)                # rand_b: 62 bits
    return uuid.UUID(int=value)


u = uuid7_simple()
print(u, "version:", u.version)
print("embedded ms:", u.int >> 80, "now ms:", time.time_ns() // 1_000_000)

Running it prints a UUID whose third group starts with 7, reports version 7, and shows an embedded millisecond value equal to the current time. On Python 3.14 and later, prefer the standard library function; its UUID objects expose the timestamp through the time attribute, in milliseconds since the Unix epoch for version 7.

What UUIDv7 gives away

A time-ordered ID carries its creation time, and anyone who can see the ID can read it. RFC 9562 describes this as a very small attack surface: the timestamp reveals when and in what order records were created, not what they contain. Even so, that can matter. A public order or invoice ID can disclose when an account was opened or roughly how fast records are being created. The RFC recommends version 4 when UUIDs are used in any security context, and states that a UUID should never be treated as a security capability, meaning that knowing an ID must not be enough to access the record behind it.

There are two other limits to keep in mind. The embedded time is the generator's clock reading, so clock skew between machines produces IDs that are out of order across hosts, and the PostgreSQL documentation notes that an extracted timestamp is not necessarily exactly equal to the generation time. And a UUIDv7 is still a 16-byte key, twice the size of a 64-bit integer, which affects every index and foreign key that stores it.

A practical checklist

  • Use UUIDv7 for new primary keys that need to be generated outside the database or merged across systems, and that live in a B-tree index.
  • Keep UUIDv4 for values that are exposed publicly and where creation time is sensitive, such as tokens, invitation codes, or externally visible identifiers. Some designs pair an internal UUIDv7 key with a separate random public ID.
  • Never rely on any UUID, of any version, as an access control check.
  • Store UUIDs in a native 128-bit type rather than as text. RFC 9562 points out that the text form takes 288 bits, more than twice the binary size.
  • Avoid name-based version 3 or version 5 UUIDs as primary keys, since the names they are derived from may change, which the RFC also cautions against.
  • If strict ordering within a millisecond matters, confirm which monotonicity method your generator uses, and generate IDs in one place when possible.
  • Measure insert throughput, index size, and write-ahead log volume on your own data before and after switching.

The short version: UUIDv4 and UUIDv7 are equally unique, but they arrive at the index in very different orders. When IDs are primary keys in a sorted index, putting the timestamp first lets a distributed ID behave much like a sequence on the write path, and the main thing to decide is whether exposing creation time is acceptable for the data in question.

Share

Enjoyed this?

Get the next deep dive in your inbox. No spam — just the stories worth reading.

Subscribe to the newsletter

Comments