Syed Hasibur Rahman28 min read

The Primary Key That Knew What Time It Was: UUIDv7, RFC 9562, and Choosing Identifiers That Age Well

RFC 9562 replaced the 2005 UUID specification in May 2024 and added UUIDv7: a 48-bit Unix millisecond timestamp followed by random bits, so identifiers sort by creation time and land at the right edge of a B-tree instead of scattering across it. PostgreSQL 18, Python 3.14, and .NET 9 now generate them natively, each with a different answer to what happens inside one millisecond. This post walks through the layout, the index-locality evidence, the monotonicity methods, byte-order traps in SQL Server and text storage, the creation-time leak and why RFC 9562 says UUIDs must never act as capabilities, and a practical pattern for keeping v7 keys inside the database while handing opaque IDs to the outside world.

It usually goes like this. A team starts with bigint identity keys because that is what the framework generated. Then a requirement arrives that integers cannot meet: mobile clients must create records offline, or two regional databases must merge, or a security review objects to /invoices/1043 sitting next to /invoices/1042. So the team switches to UUIDs. gen_random_uuid() goes in as the column default, the migration is uneventful, and everyone moves on.

Six months later the events table has a few hundred million rows and its insert latency has crept up. The buffer cache hit ratio on the primary has dropped. WAL volume is higher than the write rate seems to justify, and replicas lag during the afternoon peak. Nobody changed the schema. Nobody changed the queries. What changed is that every new row now lands at a random position in a very large index, and the database spends its days reading cold pages back into memory so it can insert one 16-byte key into each.

Someone on the team finds the fix: UUIDv7, a UUID that starts with a timestamp, so new keys sort after old ones and inserts go back to appending at the right edge of the index. Then the security reviewer reads the pull request and asks the obvious question: doesn’t that put the creation time of every record into every URL?

Both of them are right, and that tension is what this post is about. In May 2024 the IETF published RFC 9562, which replaced the 2005 UUID specification and standardized three new versions, including the time-ordered v71. Since then the major runtimes have shipped native generators: PostgreSQL 18 in September 20252, Python 3.14 in October 20253, and .NET 9 before either4. The question is no longer whether you can use time-ordered UUIDs. It is which jobs your identifiers have to do, which of those jobs v7 does well, and where you need something else alongside it.


1. Five jobs an identifier is asked to do

Most arguments about ID formats are really arguments about which job matters most, held between people who have not noticed they rank them differently. It helps to list them.

  1. Uniqueness. Two records never get the same ID, within the scope that matters. That scope might be one table, one database, or every system that will ever exchange data with yours.
  2. Generation without coordination. IDs can be minted wherever records are created (an application server, a phone with no signal, a second region) without asking a central authority. A database sequence fails this test by design.
  3. Locality. IDs created close together in time sort close together, so the storage engine writes them to the same few pages.
  4. Compactness. The ID is small enough that storing it in every row, every foreign key, and every secondary index is cheap.
  5. Opacity. The ID reveals nothing about the record, its neighbours, or your business. You cannot guess the next one, count how many exist, or learn when one was created.

A bigint sequence does 1, 3, and 4 well and fails 2 and 5. A random UUIDv4 does 1, 2, and 5 well, fails 3, and is twice the size of a bigint. UUIDv7 does 1, 2, and 3 well, costs the same as v4 on 4, and gives up part of 5 on purpose. Nothing scores well on all five, so the engineering is in deciding which job to drop and where.

2. From RFC 4122 to RFC 9562

The UUID format most developers know comes from RFC 4122, published in July 20055. It defined five versions. The two that saw wide use were version 1, built from a 60-bit timestamp and the generating host’s MAC address, and version 4, which is 122 random bits plus six fixed bits for version and variant. Versions 3 and 5 hash a name into a UUID. Version 2 is a DCE security variant almost nobody used.

Version 1 was time-based, but in a way that was awkward for databases. Its timestamp counts 100-nanosecond intervals since 15 October 1582, and the low-order bits of the timestamp come first in the byte layout. So v1 values do not sort by time as raw bytes. RFC 9562’s motivation section lists the problems: v1’s epoch is “uncommon and difficult to represent accurately using a standard number format”, “introspection/parsing is required to order by time sequence”, and exposed MAC addresses “can be used as an attack surface to locate network interfaces.” It also says versions that are not time-ordered, such as v4, have “poor database-index locality”1.

RFC 9562, by K. Davis, B. Peabody, and P. Leach, was published in May 2024 on the Standards Track and obsoletes RFC 41221. It keeps versions 1 through 5 and adds:

  • UUIDv6: v1 with the timestamp fields reordered so the value sorts by time. It exists for systems that already depend on v1. The RFC is direct about everyone else: “Implementations SHOULD utilize UUIDv7 instead of UUIDv1 and UUIDv6 if possible.”
  • UUIDv7: a Unix-epoch millisecond timestamp followed by random bits. This is the new default for time-ordered IDs.
  • UUIDv8: a free-form layout where only the version and variant bits are fixed and the other 122 bits are up to the implementation. It is for experimental and vendor-specific formats, and the RFC says its uniqueness is implementation-specific and must not be assumed.
  • The Max UUID (all 128 bits set), joining the existing Nil UUID (all bits zero), as a sentinel value.

The RFC also adds a long section of implementation guidance (Section 6) covering timestamps, counters, distributed generation, storage, and sorting. That section is where most of the useful detail lives, and most of this post draws on it.

3. UUIDv7, bit by bit

A UUIDv7 is 128 bits laid out like this (Section 5.7)1:

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                           unix_ts_ms                          |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|          unix_ts_ms           |  ver  |       rand_a          |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|var|                        rand_b                             |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                            rand_b                             |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
  • unix_ts_ms (48 bits): milliseconds since the Unix epoch, big-endian, with leap seconds excluded. 2^48 milliseconds runs out in about the year 10889, which the PostgreSQL docs give as the upper bound of uuidv7()’s range6.
  • ver (4 bits): 0111, so the first hex digit of the third group is always 7.
  • rand_a (12 bits): random, or used for extra clock precision or a counter (see Section 5).
  • var (2 bits): 10, the RFC variant, so the first hex digit of the fourth group is always 8, 9, a, or b.
  • rand_b (62 bits): random, or partly a counter.

Because the timestamp is in the most significant bits and big-endian, two v7 values compare by creation time whether you compare them as integers, as raw bytes, or as lowercase hex strings. Section 6.11 makes this an explicit design goal: the formats are “intended to be lexicographically sortable while in the textual representation” and should “sort as opaque raw bytes without the need for parsing or introspection.”

You can read the timestamp straight out of the string. PostgreSQL’s documentation uses 019535d9-3df7-79fb-b466-fa907fa17f9e as its example6. The first twelve hex digits, 019535d93df7, are 1,740,365,184,503 milliseconds, which is 2025-02-24 02:46:24.503 UTC. The docs show uuid_extract_timestamp() returning exactly that, displayed in US Eastern time as 2025-02-23 21:46:24.503-05. Anyone who can see the ID can do this arithmetic. Section 9 comes back to that.

4. Why random keys hurt B-trees

A B-tree keeps keys in sorted order across fixed-size pages. When a new key arrives, the database finds the leaf page where it belongs and inserts it there, splitting the page if it is full. Where that leaf page is depends entirely on the key.

With a sequence or a time-ordered key, almost every insert goes to the rightmost leaf. That page is hot, it stays in memory, and once it is full the database starts a new one. The working set for inserts is a handful of pages however large the index grows.

With a random key, every insert goes to a uniformly random leaf. Once the index is larger than memory, most inserts need a page that is not cached, so the database reads it from disk, modifies it, and eventually writes it back. Pages split all over the index rather than only at the edge, so they end up partly empty. In PostgreSQL, the first change to each page after a checkpoint also writes a full-page image to the WAL. Random inserts touch many more distinct pages between checkpoints, so they produce more of those images. The result is the combination from the opening: more reads, more WAL, and slower replicas.

The effect is worse in engines that store the table itself in primary-key order. MySQL’s InnoDB stores row data in the clustered index, which “typically … is synonymous with the primary key”, and each secondary index record “contains the primary key columns for the row.” The manual’s conclusion follows: “If the primary key is long, the secondary indexes use more space, so it is advantageous to have a short primary key”7. A random primary key in InnoDB scatters the rows themselves, not just an index.

There are two good public data points.

Cybertec’s index-only scan benchmark (2023). Ants Aasma built a 10-million-row PostgreSQL table with a bigint key, a UUIDv4 column, and a UUIDv7 column, each with its own unique index, and with everything pre-warmed into memory8. The two UUID indexes were the same size: 301 MB each, against 214 MB for the bigint. But an index-only count(*) took 526 ms on the bigint index, 541 ms on the v7 index, and 1,214 ms on the v4 index. With EXPLAIN (ANALYZE, BUFFERS), the v4 scan recorded about 8.56 million shared-buffer hits against about 39,000 for v7. The extra hits came from visibility-map lookups: the index is read in key order, and random keys mean consecutive entries point to unrelated heap pages. Aasma’s conclusion: “it’s the random ordering that is causing us issues, not the UUID type itself.”

That result is worth stating plainly because it corrects a common belief. The cost of v4 is mostly disorder, not size. A v7 key is just as wide as a v4 key. What you get back by switching is locality.

Buildkite’s production change (2023). Buildkite had used sequential integer primary keys with UUIDs as secondary keys. Its engineering blog describes a period of experimenting with time-ordered identifiers and then a decision to “use UUIDv7 as the primary key for all new tables”9. Over six weeks, that work together with several other changes produced a “50% reduction in the WAL (Write Ahead Log) rate of the primary database”, along with a similar reduction in write I/O. The post is careful to say several changes contributed, so do not quote the 50% as the effect of the ID format alone. It does show the direction. The same post describes a trap: time-ordered IDs that kept v4’s version bits worked, but setting the version nibble to 7 broke downstream customer systems that validated UUIDs as v4. Strict validators are part of your API surface too.

5. What happens inside one millisecond

A millisecond is a long time. A busy service can generate thousands of IDs in one, and the timestamp alone cannot order them. If the remaining 74 bits are purely random, IDs from the same millisecond sort in random order. That is harmless for index locality, since they all land on the same page anyway, but it breaks anything that assumes “sorted by ID” means “sorted by creation”, such as cursor pagination, event-log replay, or tie-breaking.

RFC 9562 Section 6.2 offers three ways to make IDs monotonic within a millisecond1:

  • Method 1, fixed bit-length dedicated counter. Reserve some of the bits after the timestamp for a counter that increments for each ID in the same millisecond. The guidance is that “the counter SHOULD be at least 12 bits but no longer than 42 bits.” Seed the counter randomly at each new millisecond, and keep its top bit or bits clear as a rollover guard so the increments have room. The RFC is firm about overflow: “Counter rollovers MUST be handled by the application to avoid sorting issues.”
  • Method 2, monotonic random. Treat the random bits as a number and add a random increment for each ID in the same millisecond. The sequence stays ordered and the next value is still hard to predict.
  • Method 3, replace leftmost random bits with increased clock precision. Use up to 12 bits after the millisecond timestamp for a sub-millisecond time fraction, which gives ordering finer than a millisecond.

The runtimes that now ship v7 generators each made a different choice, and it matters which one you are using.

PostgreSQL 18 uses Method 3. The release notes credit Andrey Borodin with uuidv7(), describe the values as “temporally sortable”, and note that uuidv4() was added as an explicit alias for gen_random_uuid()2. The source comments in uuid.c say it uses the RFC’s “Replace Leftmost Random Bits with Increased Clock Precision (Method 3)”, storing a 12-bit sub-millisecond fraction in rand_a, and that each timestamp is “ensured to be at least SUBMS_MINIMAL_STEP greater than the previous returned timestamp (on this backend)”10. Note the scope: on this backend. Two connections can generate values that interleave out of order within the same instant. The function also takes an optional shift interval that moves the embedded timestamp, which is useful for backfills, as long as the result stays within the 48-bit range6.

Python 3.14 uses Method 1 at the maximum length. The documentation says uuid.uuid7() embeds “a 48-bit timestamp and use[s] a 42-bit counter to guarantee monotonicity within a millisecond,” chosen “for portability across platforms lacking sub-millisecond precision”11. The same release added uuid6(), uuid8(), NIL, and MAX.

.NET 9 provides Guid.CreateVersion7() and an overload that takes a DateTimeOffset. Its documentation says only that the method “seeds the rand_a and rand_b sub-fields with random data”4. It describes no counter, so do not assume two GUIDs created in the same millisecond come out in order.

The uuid npm package implements v7() with an optional 32-bit seq. Its README says that without explicit options, “internal state is utilized to improve UUID uniqueness”, but once you pass options, “internal state is NOT used”12. If you pass a fixed msecs for testing, you lose its ordering behaviour.

The practical rule: if anything depends on ID order inside a millisecond, generate those IDs in one place with one generator that documents a counter, or order by a separate column. Do not rely on two generators agreeing.

6. A portable generator, tested

If you are on PostgreSQL 17 or Python 3.13 or older, or you want identical behaviour across services, you can write a generator in a few dozen lines. This one implements Method 1 with a 42-bit counter split across rand_a (12 bits) and the top of rand_b (30 bits), followed by 32 fresh random bits per ID. It also handles a clock that steps backwards, which the RFC tells implementations to consider (Section 6.1).

import os
import threading
import time
import uuid

_COUNTER_BITS = 42          # RFC 9562 §6.2: at least 12, no more than 42
_COUNTER_MAX = (1 << _COUNTER_BITS) - 1


class UUIDv7Generator:
    """RFC 9562 UUIDv7 with a fixed-length dedicated counter (Method 1).

    Layout: 48-bit unix_ts_ms | ver=7 | 12 bits counter (high) | var=10 |
            30 bits counter (low) + 32 bits random.
    """

    def __init__(self):
        self._lock = threading.Lock()
        self._last_ms = -1
        self._counter = 0

    def _fresh_counter(self) -> int:
        # Seed with random bits but leave the top bit clear as a rollover guard
        return int.from_bytes(os.urandom(6), "big") & (_COUNTER_MAX >> 1)

    def new(self) -> uuid.UUID:
        with self._lock:
            now_ms = time.time_ns() // 1_000_000
            if now_ms > self._last_ms:
                self._last_ms = now_ms
                self._counter = self._fresh_counter()
            else:
                # Same millisecond, or the clock went backwards: keep the
                # last timestamp and count forward so ordering never breaks.
                self._counter += 1
                if self._counter > _COUNTER_MAX:
                    self._last_ms += 1          # borrow from the future
                    self._counter = self._fresh_counter()
            ms, counter = self._last_ms, self._counter

        rand = int.from_bytes(os.urandom(4), "big")
        value = (ms & 0xFFFF_FFFF_FFFF) << 80
        value |= 0x7 << 76                          # version
        value |= (counter >> 30) << 64              # 12 high counter bits
        value |= 0b10 << 62                         # variant
        value |= (counter & ((1 << 30) - 1)) << 32  # 30 low counter bits
        value |= rand
        return uuid.UUID(int=value)


def uuid7_timestamp_ms(u: uuid.UUID) -> int:
    if u.version != 7:
        raise ValueError("not a UUIDv7")
    return u.int >> 80

Three design decisions are worth explaining.

Clock rollback reuses the last timestamp. If NTP steps the clock back by two seconds, a naive generator would emit IDs that sort before ones it issued a moment ago. This one keeps the last timestamp and keeps counting. The embedded time is briefly ahead of the wall clock, which the RFC allows (“Implementations MAY alter the actual timestamp”), and order is preserved.

Overflow borrows a millisecond rather than failing. With a 41-bit usable range after the guard bit, you would need about two trillion IDs in one millisecond to reach it, so this path exists for correctness rather than in expectation. ULID’s specification takes the other option and makes generation fail on overflow13. Both are defensible. Failing silently is not.

The random tail is fresh per ID. A counter makes consecutive IDs adjacent in value. Keeping 32 random bits at the end means an observer who sees one ID still cannot write down the next one.

I tested this on CPython 3.13 with 200,000 IDs generated in a tight loop. All of them parse as version 7 with the RFC variant, all are unique, and the list is already sorted whether compared as UUID objects, as 16-byte strings, or as hex text. A simulated ten-second backwards clock step also kept ordering intact. The test is short enough to keep next to the generator:

ids = [g.new() for _ in range(200_000)]
assert all(u.version == 7 and u.variant == uuid.RFC_4122 for u in ids)
assert ids == sorted(ids)
assert [u.bytes for u in ids] == sorted(u.bytes for u in ids)
assert [str(u) for u in ids] == sorted(str(u) for u in ids)
assert len(set(ids)) == len(ids)

The generator is per-process. Two processes each produce sorted streams, but the streams interleave arbitrarily within a millisecond. Section 6.4 of the RFC is candid about distributed generation: “as the total number of UUID generating nodes increases, so does the likelihood of a collision,” and every node is relying on every other node’s random source.

7. Storage and byte-order traps

The sort-by-bytes property only helps if every layer between your code and the disk keeps those bytes in order. Several layers do not.

Store the 128 bits, not the string. Section 6.13 says UUIDs “SHOULD be stored within database applications as the underlying 128-bit binary value”, and points out that the text form is “unnecessarily verbose, requiring 288 bits to represent 128-bit UUID values”1. PostgreSQL’s uuid type is the 16-byte binary form. In MySQL, use BINARY(16) and convert at the edges rather than CHAR(36). A CHAR(36) key is more than twice the size, and with InnoDB that cost is repeated in every secondary index.

SQL Server sorts uniqueidentifier by the last six bytes first. Microsoft’s ADO.NET documentation says ordering in SQL Server “is not implemented by comparing the bit patterns of the two values”, and that SqlGuid follows SQL Server behaviour, “in [which] the last six bytes of a value are most significant”, while System.Guid “evaluates all 16 bytes”14. The documentation’s example sorts three GUIDs in a different order under each type. For UUIDv7 this matters a lot. The timestamp is in the first six bytes, which SQL Server weighs least, so a column of v7 values stored as uniqueidentifier does not cluster by time. If you are on SQL Server and want insert locality, either rearrange the bytes so the timestamp lands where SQL Server looks first (understanding that the stored value is then no longer a standard v7), use NEWSEQUENTIALID() for server-generated keys, or store BINARY(16).

Watch for libraries that reorder bytes. Some platforms have historically serialized the first three UUID fields in little-endian order when converting to bytes. The standard’s network byte order is big-endian, and the sort property depends on it. If a v7 value goes in through one driver and comes out of another in a different order, test the round trip before you build pagination on it.

Do not parse unless you have to. Section 6.12 recommends “avoiding parsing UUID values unnecessarily … instead, treat UUIDs as opaquely as possible.” Pulling the creation time out of a v7 key is tempting, since it saves a created_at column. But generators are allowed to shift, fuzz, or smear the timestamp (Section 6.1, and PostgreSQL’s shift parameter does exactly that), and the PostgreSQL docs warn that the extracted timestamp “is not necessarily exactly equal to the time the UUID was generated”6. Keep created_at as a real column if anything depends on it, such as billing, retention, or audit. Treat the ID’s timestamp as a locality hint.

8. Is a v4 secret? The RFC says no

Before deciding how much v7 leaks, settle what a UUID was ever supposed to protect. RFC 9562’s security considerations are blunt1:

Implementations SHOULD NOT assume that UUIDs are hard to guess. For example, they MUST NOT be used as security capabilities (identifiers whose mere possession grants access).

That covers v4 too. A v4 drawn from a CSPRNG has 122 random bits and is not practically guessable. But the specification does not promise that every implementation uses a CSPRNG (Section 6.9 says they SHOULD), and UUIDs are not designed to be handled like secrets. They are logged, put in URLs, kept in browser history, sent to analytics, and pasted into support tickets. An unguessable ID that has been copied into ten systems is not a secret.

So “random IDs prevent enumeration” is a defence in depth at best. OWASP’s API Security Top 10 ranks Broken Object Level Authorization first in its 2023 edition. Its prevention guidance does say “Prefer the use of random and unpredictable values as GUIDs for records’ IDs”, but only after the requirement that actually fixes the bug: use the authorization mechanism “to check if the logged-in user has access to perform the requested action on the record” for every function that takes a client-supplied ID15. If /invoices/{id} returns another tenant’s invoice when given that tenant’s ID, the vulnerability is the missing check. Whether the ID was easy to guess only decides how fast someone finds it.

Seen this way, switching from v4 to v7 does not move you from secure to insecure. Neither was a security boundary. What v7 changes is how much an observer can infer from IDs they are legitimately shown.

9. What a v7 actually tells the world

A v7 ID in a URL, a webhook payload, or an API response tells anyone who sees it:

  • When the record was created, to the millisecond, unless the generator shifts the timestamp. For a user ID, that is the sign-up time. For an invoice, it is the order time. For a document, it is the creation date the user may have chosen not to show.
  • Rough relative order of any two records, and therefore rough rate information if someone collects enough of them.
  • With a counter-based generator, how many IDs were issued in one burst. Two IDs from the same millisecond with counters 4 apart suggest three were issued in between. The random tail stops anyone predicting the next ID, but it does not hide the gap.

What a v7 does not reveal, unlike a sequence, is a global count. /users/48213 tells a competitor roughly how many users you have. A v7 tells them when this one signed up. The RFC calls timestamps “a very small attack surface”, and for most records that is fair1. It is not fair for every record. Creation times of medical appointments, legal filings, account sign-ups in a sensitive service, or anything a user has been told is private are data, and putting them into an identifier publishes them. The same paragraph of the RFC says that if UUIDs are needed for security operations, “UUIDv4 … SHOULD be utilized.”

So the useful question is not “v4 or v7?” It is “which IDs leave the database, and is their creation time something we would publish in a response field?” If the answer is yes, as with most order numbers, public posts, and log events, use v7 everywhere and move on. If the answer is no, keep v7 for the database and give the outside world something else.

10. Two IDs per record, or one encrypted

There are two clean ways to have locality on the inside and opacity on the outside.

Pattern A: an internal v7 key plus a public v4 column. The primary key and every foreign key use v7. A separate public_id uuid NOT NULL UNIQUE DEFAULT uuidv4() column is the only identifier the API accepts or returns. This is simple, every database supports it, and the v4 index is the only one that suffers from random inserts. The cost is a second unique index and an extra lookup on every API request, plus the discipline to make sure the internal key never appears in a response, a log line, or an error message.

Pattern B: encrypt the v7 for the outside world. A UUID is exactly 128 bits, which is one AES block. Encrypting one block under a secret key is a keyed permutation of the 128-bit space: every input maps to a distinct output, the mapping is reversible with the key, and without the key the output looks random. No timestamp, no counter, no order. You store only the v7 and translate at the API boundary.

import base64
import uuid

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes


class ExternalIds:
    """Map internal UUIDv7 keys to opaque public IDs and back.

    A UUID is exactly one AES block, so a single-block encryption under a
    secret key is a keyed permutation of the 128-bit space: unique in,
    unique out, reversible, and with no visible timestamp.
    """

    def __init__(self, key: bytes, prefix: str):
        if len(key) not in (16, 32):
            raise ValueError("AES-128 or AES-256 key required")
        self._cipher = Cipher(algorithms.AES(key), modes.ECB())
        self._prefix = prefix

    def encode(self, internal: uuid.UUID) -> str:
        enc = self._cipher.encryptor()
        block = enc.update(internal.bytes) + enc.finalize()
        text = base64.b32encode(block).decode().rstrip("=").lower()
        return f"{self._prefix}_{text}"

    def decode(self, public: str) -> uuid.UUID:
        prefix, _, text = public.partition("_")
        if prefix != self._prefix or len(text) != 26:
            raise ValueError("malformed id")
        block = base64.b32decode(text.upper() + "======")
        dec = self._cipher.decryptor()
        internal = uuid.UUID(bytes=dec.update(block) + dec.finalize())
        if internal.version != 7 or internal.variant != uuid.RFC_4122:
            raise ValueError("unknown id")   # forged or wrong key
        return internal

Five consecutive v7 keys from the generator in Section 6, generated in the same millisecond, came out like this:

01a1670c-17a3-7314-8860-7eb315e60467  ->  inv_woynibmpsm6r2yaocximzapr24
01a1670c-17a3-7314-8860-7eb4f9443fa2  ->  inv_fkh4deng4jbpk5sn6dbqvg23xe
01a1670c-17a3-7314-8860-7eb5756599ac  ->  inv_hdwpaeyyflcqprw4bii6qdx5ve

The internal values share their first 19 hex digits. The public ones share nothing but the prefix.

ECB has a bad reputation, and for encrypting messages it deserves it, because identical blocks produce identical ciphertext and patterns show through. Here that determinism is the point: the same record must always produce the same public ID, and every input is exactly one block, so there is no pattern across blocks to leak. Being deterministic does not make this a substitute for authorization, though. The version and variant check in decode rejects about 63 out of 64 random forgeries cheaply (I measured 1,968 rejections out of 2,000 random inputs, in line with the expected 98.4%). It is a filter for junk before the database lookup. It is not access control, and Section 8 still applies to every request.

The costs are operational. The key is now a dependency of every public URL, so it must be backed up as carefully as the database, and it cannot be rotated without changing every public ID you have ever issued. Plan for that up front: put a key-version character in the prefix (inv1_, inv2_) so a future key can coexist with the old one. And keep the internal keys out of logs that leave your trust boundary, or the encryption is wasted.

The prefix_ shape is borrowed from TypeID, a specification for type-prefixed, base32-encoded UUIDv7 strings such as user_2x4y6z8a0b1c2d3e4f5g6h7j8k, with a lowercase prefix of up to 63 characters16. A TypeID decodes back to a plain v7, so it does not hide the timestamp. The prefix idea is worth taking on its own merits, though. An identifier that says what it identifies catches a whole class of bugs where a user ID is passed where an invoice ID was expected, and it makes support tickets and logs much easier to read.

11. The alternatives, briefly

UUIDv7 did not appear from nowhere. It standardized ideas that had been in production for over a decade.

Twitter’s Snowflake (2010) was built for three requirements its README lists as uncoordinated, roughly time-ordered (“k-sorted”), and compact: “we need to keep our ids under 64bits.” Its layout is a 41-bit millisecond timestamp with a custom epoch (“gives us 69 years”), a 10-bit configured machine ID (“up to 1024 machines”), and a 12-bit sequence that “rolls over every 4096 per machine”17. It fits in a bigint, which is half the size of any UUID. The price is coordination of a different kind: every generator needs a unique machine ID, and assigning those safely across autoscaling fleets is a real operational problem. Snowflake-style IDs are still the right choice when key width matters more than anything else.

ULID is a 48-bit millisecond timestamp plus 80 random bits, written as 26 characters of Crockford’s Base32, an alphabet that “excludes the letters I, L, O, and U to avoid confusion and abuse”13. Its monotonic mode increments the random part within a millisecond and fails generation on overflow. A ULID is essentially a v7 with no version or variant bits and a friendlier text encoding. If you already use ULIDs, there is no urgent reason to migrate, but new systems get better database and library support from v7.

bigint identity remains the best choice when IDs never leave a single database and never need to be generated anywhere else. It is the most compact, has perfect locality, and every tool understands it. Its problems are the ones from the opening: it needs the database to mint IDs, and it leaks counts when exposed. Pattern A from Section 10 works just as well with a bigint key inside and a v4 outside.

SizeCoordination-freeIndex localityLeaks
bigint sequence8 bytesNoExcellentCount and order
Snowflake8 bytesMachine ID neededVery goodTime, machine, order
UUIDv416 bytesYesPoorNothing structural
UUIDv7 / ULID16 bytesYesVery goodCreation time, order
v7 inside, encrypted outside16 bytesYesVery goodNothing structural, if the key is kept

12. Migrating an existing system

If you have v4 keys today and the symptoms from the opening, the change is less dramatic than it sounds.

New rows first. Change the column default to uuidv7() on PostgreSQL 18, or generate v7 in the application on older versions. Both kinds of UUID can sit in the same column, because both are valid UUIDs. Older rows stay scattered, but new rows start appending at the right edge, and the working set for inserts shrinks right away. Check first that no consumer validates the version nibble, which is the Buildkite lesson.

Do not rewrite existing keys unless you have a strong reason. Every foreign key, cache entry, external reference, and bookmarked URL depends on them. The locality gain is for future inserts. Old pages that are already scattered rarely matter, because hot data tends to be recent data.

Backfills need care with timestamps. If you are generating keys for historical rows, PostgreSQL’s uuidv7(shift) lets you embed an approximate original time, so the rows sort where they belong rather than all landing at the end6. Whether you want that depends on whether anything orders by key. Either way, keep the real created_at column authoritative.

Decide where IDs may be minted. One of the attractions of UUIDs is that clients can generate them, for example for offline-first mobile apps or for idempotent creates. But a client-generated v7 carries a client-chosen timestamp. A device with a wrong clock, or a malicious client, can create IDs that sort into the past or the far future, and so control where its rows appear in any ordering by ID. Treat client-supplied IDs as untrusted input: validate the version and the timestamp range on the server, or let clients supply an idempotency key while the server assigns the primary key.

Decide per entity what goes outside. Go through the entities your API exposes and ask the question from Section 9. Most will be fine with v7 in the open. For the few where creation time is sensitive, use Pattern A or B before the first public release, because changing a public ID format after launch is a migration for every client you have.

Closing: pick the leak on purpose

The history of database identifiers is a series of formats, each fixing one job and quietly giving up another. Sequences gave up coordination-free generation and opacity. UUIDv4 got those back and gave up locality. UUIDv7 gets locality back and gives up part of the opacity, deliberately and in plain sight, with a specification that tells you exactly which bits are a timestamp.

That last point is what makes RFC 9562 a real improvement and not just another format. The trade-off is now explicit, standard, and supported by the database, the language runtime, and the specification, all agreeing on the same 128 bits. The engineering decision that remains is small and should be made on purpose, not by whatever the ORM generated: use v7 as the primary key almost everywhere, keep a real created_at column, never let any ID stand in for an authorization check, and for the handful of entities whose creation time is nobody else’s business, translate at the boundary.

Every identifier format gives up one of the five jobs. Make sure you know which one, rather than finding out from someone reading your URLs.


References


  1. Davis, K., Peabody, B., and Leach, P., “Universally Unique IDentifiers (UUIDs)”, IETF RFC 9562, May 2024. Standards Track; obsoletes RFC 4122. https://www.rfc-editor.org/rfc/rfc9562 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  2. PostgreSQL Global Development Group, “PostgreSQL 18.0 Release Notes”, released 25 September 2025. https://www.postgresql.org/docs/release/18.0/ ↩︎ ↩︎

  3. Python Software Foundation, “What’s New In Python 3.14” (Python 3.14.0 released 7 October 2025). https://docs.python.org/3/whatsnew/3.14.html ↩︎

  4. Microsoft, “Guid.CreateVersion7 Method”, .NET API reference (.NET 9 and later). https://learn.microsoft.com/en-us/dotnet/api/system.guid.createversion7 ↩︎ ↩︎

  5. Leach, P., Mealling, M., and Salz, R., “A Universally Unique IDentifier (UUID) URN Namespace”, IETF RFC 4122, July 2005. https://www.rfc-editor.org/rfc/rfc4122 ↩︎

  6. PostgreSQL Global Development Group, “9.14. UUID Functions”, PostgreSQL 18 documentation. https://www.postgresql.org/docs/18/functions-uuid.html ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  7. Oracle, “17.6.2.1 Clustered and Secondary Indexes”, MySQL 8.4 Reference Manual. https://dev.mysql.com/doc/refman/8.4/en/innodb-index-types.html ↩︎

  8. Aasma, A., “Unexpected downsides of UUID keys in PostgreSQL”, CYBERTEC PostgreSQL, June 2023. https://www.cybertec-postgresql.com/en/unexpected-downsides-of-uuid-keys-in-postgresql/ ↩︎

  9. Chan, G., “Goodbye integers. Hello UUIDv7!”, Buildkite Engineering, 25 September 2023. https://buildkite.engineering/goodbye-integers-hello-uuids/ ↩︎

  10. PostgreSQL source, src/backend/utils/adt/uuid.c, REL_18_STABLE branch (comments on generate_uuidv7 and get_real_time_ns_ascending). https://github.com/postgres/postgres/blob/REL_18_STABLE/src/backend/utils/adt/uuid.c ↩︎

  11. Python Software Foundation, “uuid — UUID objects according to RFC 9562”, Python 3 documentation (uuid7(), added in version 3.14). https://docs.python.org/3/library/uuid.html ↩︎

  12. uuidjs contributors, “uuid” npm package, README (sections on v7() and options handling for timestamp UUIDs). https://github.com/uuidjs/uuid ↩︎

  13. ulid contributors, “The canonical spec for ulid”, GitHub. https://github.com/ulid/spec ↩︎ ↩︎

  14. Microsoft, “Comparing GUID and uniqueidentifier values”, ADO.NET Provider for SQL Server documentation. https://learn.microsoft.com/en-us/sql/connect/ado-net/sql/compare-guid-uniqueidentifier-values ↩︎

  15. OWASP, “API1:2023 Broken Object Level Authorization”, OWASP API Security Top 10, 2023 edition. https://api-security.owasp.org/editions/2023/en/0xa1-broken-object-level-authorization ↩︎

  16. Jetify, “TypeID”, GitHub README and specification (latest spec version v0.3.0). https://github.com/jetify-com/typeid ↩︎

  17. Twitter, “Snowflake”, README, snowflake-2010 branch, GitHub (archived). https://github.com/twitter-archive/snowflake/tree/snowflake-2010 ↩︎