UUID v7 monotonicity: same-millisecond IDs sort wrong

A UUID v7 sorts by creation time across different milliseconds, but inside a single millisecond the timestamp cannot separate two values and an implementation that fills the remaining 74 bits with pure randomness orders them at random. Generating 10,000 identifiers in 20 milliseconds, 50.2% of adjacent pairs came back smaller than the one before.

What the measurement shows

The appeal of version 7 is that string order matches creation order, so a UUID can be a database primary key without scattering inserts across a B-tree the way a version 4 value does. That property holds between milliseconds and stops holding inside one. Because the first 48 bits are a millisecond timestamp, two values minted in the same millisecond share an identical prefix, and the comparison continues into whatever the implementation put next.

All figures below were produced on Node v23 against two generators: the one this site ships, which carries a counter, and a stripped version that fills everything after the timestamp with random bytes. The second is the shape most hand-rolled implementations take.

                 milliseconds   inverted pairs
with counter              20        0 / 9,999   (0.0%)
without counter           26    5,018 / 9,999  (50.2%)

Half of all adjacent pairs out of order is not a tail risk, and it is not bad luck either. Once two values share a timestamp their relative order is decided by random bits, so roughly half of those comparisons land the wrong way by construction. In a shorter run of 2,000 values the very first pair was already reversed:

01a11016-34a4-7df6-a798-c039267cb4d3   generated first
01a11016-34a4-71be-ba4e-7de80367601a   generated second, sorts lower

The first thirteen characters are identical. That shared prefix is the timestamp plus the version nibble, and everything after it is where the ordering was supposed to come from.

What RFC 9562 actually says

The specification does not claim the format solves this on its own. Section 6.2 observes that time-based UUIDs are “normally” monotonic thanks to the embedded timestamp, then states that implementations can guarantee additional monotonicity, and that they SHOULD do so when generating batches. Three methods follow, and they are easy to mix up because two of them write into the same field.

Method 1, Fixed Bit-Length Dedicated Counter, reserves bits immediately after the timestamp and increments them once per identifier. For version 7 that field is rand_a, twelve bits, and the specification allows spilling into the leftmost bits of rand_b if more counter range is needed. Method 2, Monotonic Random, puts a randomly seeded counter in rand_b instead and increments it in the least significant position by a random step, which keeps values unguessable — the specification explicitly discourages incrementing by one where guessability matters. Method 3, Increased Clock Precision, uses real sub-millisecond clock resolution in place of up to twelve random bits, scaling the fraction of a millisecond across the 4,096 values rand_a can represent.

The generator on this site uses method 1 with one adjustment: the counter is seeded from random bits below its midpoint at the start of each millisecond rather than from zero. Starting at zero would make the leading bits of every first-of-millisecond identifier predictable, and seeding low leaves headroom before a burst has to borrow.

The trap: libraries pick different methods, and say so quietly

Because all three methods preserve the 128-bit layout, two libraries can both be correct, both be conformant, and still disagree about what the bits after the timestamp mean. Nothing in the value marks which method produced it.

PostgreSQL 18 is a useful example because its choice is documented rather than implied. Its native uuidv7() describes the timestamp as a Unix millisecond value plus a sub-millisecond timestamp plus random bits — that is method 3, ordering derived from the clock itself. A client-side generator using method 1 produces values that are internally monotonic for that client, but its counter and the database’s clock fraction are unrelated quantities. Values from the two sources interleave correctly by millisecond and arbitrarily within one.

This matters if you generate identifiers in the application for some rows and let the database default handle others, which is a common migration pattern. The ordering guarantee you tested in one path does not transfer to the mixed path. It is not a correctness bug — no duplicates, no invalid values — which is exactly why it survives review.

Where the disorder actually hurts

Index locality degrades first, and mildly. Values still cluster by millisecond, so inserts land in a narrow region of the B-tree even when shuffled inside it; you lose some of version 7’s advantage rather than all of it.

Keyset pagination is the quieter failure. A query that walks rows with WHERE id > $1 ORDER BY id assumes identifier order and insertion order agree. When several rows share a millisecond and their order disagrees with the order they were written, a row can fall on the wrong side of the cursor and be skipped, or appear on two consecutive pages. The behaviour depends on how many rows share a timestamp, so it stays invisible in testing and shows up under load — the regime where a page of results quietly missing a row is hardest to notice.

Checking your own library

One loop settles it. Generate enough values that many share a millisecond, then confirm each is strictly greater than the one before.

const ids = Array.from({ length: 10000 }, () => uuidv7());
const ok = ids.every((v, i) => i === 0 || v > ids[i - 1]);
console.log(ok);

A false means same-millisecond ordering is random. That may be perfectly acceptable — if identifiers are only ever compared across requests seconds apart, the distinction never surfaces. The point is to know which guarantee you have before building a cursor on top of it.

Worth testing separately: what the library does when a counter overflows. Twelve bits hold 4,096 values, and a larger burst has to resolve the overflow somehow. Borrowing the next millisecond keeps ordering and drifts ahead of the clock; blocking until the clock advances keeps the timestamp honest and costs latency. Generating 5,000 values at once against the implementation here — past the 4,096 limit — produced no inversions and spread across 7 timestamps. A library that wraps the counter to zero instead would produce a descending run at exactly that boundary.

Frequently asked questions

Is UUID v7 guaranteed to be sortable?

Only across different milliseconds. The first 48 bits of a v7 value hold a Unix millisecond timestamp, so two values created in different milliseconds always compare in creation order. Inside a single millisecond the timestamp is identical and the comparison falls through to whatever follows it, which in a naive implementation is pure randomness. RFC 9562 is explicit that monotonicity within a timestamp tick is something an implementation adds rather than something the format provides: section 6.2 says implementations SHOULD employ a counter method when they generate batches. So the honest statement is that v7 is time-ordered at millisecond granularity, and sortable below that only if whoever wrote your library did the extra work.

How often does same-millisecond disorder actually happen?

Far more often than the phrase 'edge case' suggests, because modern machines generate thousands of identifiers per millisecond. Generating 10,000 values in a tight loop on Node v23 took 20 milliseconds, which puts roughly 500 values under each timestamp. With a counter-less implementation, 5,018 of the 9,999 adjacent pairs came back smaller than their predecessor — 50.2%, essentially a coin flip. The same loop with a 12-bit counter in rand_a produced zero inversions. The rate is not a function of bad luck; once two values share a timestamp their ordering is decided by random bits, so you should expect about half of same-millisecond neighbours to be reversed.

What are the three monotonicity methods in RFC 9562?

Section 6.2 names them. Method 1, Fixed Bit-Length Dedicated Counter, reserves a field immediately after the timestamp — for v7 that is rand_a — and increments it once per identifier within a tick. Method 2, Monotonic Random, extends the random data itself into a randomly seeded counter held in rand_b, incremented in its least significant position by a random amount so values stay unguessable. Method 3, Increased Clock Precision, replaces up to 12 leftmost random bits with sub-millisecond clock precision, scaling the fraction of a millisecond across the 4,096 values rand_a can hold. All three keep the 128-bit layout intact; they differ in which field pays for the ordering.

Which method should I use?

It depends on what you can measure and what you must hide. Method 3 is the cleanest when your clock genuinely offers sub-millisecond resolution, because ordering then comes from real time rather than bookkeeping — PostgreSQL 18's native uuidv7() takes this route, documenting its value as a millisecond timestamp plus a sub-millisecond timestamp plus random bits. Method 1 is the most portable, since a counter needs no clock guarantees at all, and 12 bits of rand_a covers 4,096 identifiers per millisecond. Method 2 matters when unguessability is the priority, because incrementing by a random step rather than by one stops an observer inferring how many identifiers you issued.

What happens when the counter runs out?

A 12-bit counter holds 4,096 values, so a burst larger than that within one millisecond exhausts it. The implementation on this site handles the overflow by borrowing the next millisecond: it increments the stored timestamp and resets the counter, which keeps ordering intact at the cost of running slightly ahead of the wall clock. Generating 5,000 values in a single burst — past the 4,096 limit — produced zero inversions and spread across 7 timestamps. The alternative is to block until the clock advances, which preserves timestamp accuracy and costs latency. Neither is wrong; what matters is that the implementation picks one rather than silently wrapping to zero.

References

Generate version 7 values with the UUID v7 generator, which uses the counter described above. For how version 7 compares with the random version as a key, see UUID v4 vs v7, and for the collision maths behind its 74 random bits, see UUID collision probability.