UUIDv7 is the default advice of 2026. Swap your random v4 primary keys for time-ordered v7 keys, the argument goes, and your B-tree stops fragmenting, your inserts stop touching random pages, and you get a free creation timestamp into the bargain. The advice is good. The problem is that nobody checks what their library actually does, because every library says the same four words in its README: RFC 9562 compliant.
A post titled UUIDv7 doesn’t have the properties you think it does landed on Hacker News on 22 October 2026, arguing from the specification text that the monotonicity and timestamp guarantees are weaker than the marketing. Good argument. We wanted the measurement, so we installed nine UUIDv7 implementations across Python and JavaScript, generated more than four million identifiers, and compared them against each other and against the clock.
Every one of the nine is compliant. They disagree about what time it is by 176 years.
TL;DR
- Inside one process, all nine implementations are perfect: 200,000 identifiers each, zero out-of-order pairs, zero duplicates, correct version and variant bits in every single one.
- Across four processes on one machine sharing one clock, ordering collapses to chance. For the four implementations that follow RFC 9562 literally, the cross-process inversion rate measured 49.84%, 49.94%, 50.01% and 50.01%. A pair of v7 identifiers from two different workers carries no ordering information at all.
- Two PyPI packages with 11.2 million downloads a month embed a timestamp that a standard decoder reads as 12 September 2202. They implement a 2021 IETF draft layout that the final RFC replaced, and that obsolete layout is the only reason they are the only two that get cross-process ordering right.
uuid6advances its embedded timestamp 100 seconds into the future in 1.5 seconds of real work, with no clock adjustment involved. Ten thousand identifiers inside a single frozen millisecond moved the timestamp field forward by 9,999 milliseconds.- One library stops emitting valid UUIDs after 4,096 in a millisecond. Identifier 4,097 is 37 characters long and is rejected by both the JavaScript and Python validators. A five-second backwards clock step produced 15,923 malformed strings in a row.
How we measured it
One Linux 6.8 host, Python 3.14.5 and Node.js 22.22.0, nothing containerised or virtualised. Nine generators: the Python standard library’s new uuid.uuid7(), PyPI’s uuid6 2025.0.1, uuid-utils 1.1.0, fastuuid 0.14.0, uuid7 0.1.0 and uuid-v7 1.0.0, plus npm’s uuid 14.0.3, uuidv7 1.2.1 and @kripod/uuidv7 0.3.4. Between them those packages account for roughly 1.4 billion downloads a month.
Four experiments. First, 200,000 identifiers in a tight single-threaded loop, recording the wall clock alongside each one. Second, the same thing from four concurrent processes, with every identifier stamped by time.time_ns() and the merged stream re-sorted into true creation order. Third, the clock frozen at a fixed millisecond. Fourth, a simulated NTP step: the clock jumps five seconds backwards mid-run.
Finding 1: the property everyone tests, everyone passes
In a single process, the result is unanimous and boring. Across 1.8 million identifiers, there was not one inversion, not one duplicate, and not one malformed version nibble or variant field. Every library does exactly what the tutorial promises.
That is the trap. The test a team writes before adopting v7 is a loop that generates ten thousand identifiers and asserts they come out sorted. Every library here passes it, and the property that loop measures is not the property a production system depends on.
Finding 2: two processes, and ordering becomes a coin flip
Your application does not generate identifiers from one process. It runs four Gunicorn workers, or eight Node cluster children, or thirty pods, all inserting into one table. So we ran four workers generating 50,000 identifiers each, stamped every one with the shared system clock, merged them into true creation order, and counted how often the next identifier sorted lower than its predecessor.
| Implementation | Inversions, same worker | Inversions, different workers |
|---|---|---|
uuid-utils 1.1.0 |
0 | 50.01% |
uuid6 2025.0.1 |
0 | 50.01% |
fastuuid 0.14.0 |
0 | 49.94% |
Python 3.14 uuid.uuid7() |
0 | 49.84% |
uuid-v7 1.0.0 |
0 | 1.44% |
uuid7 0.1.0 |
0 | 0.94% |
Every out-of-order pair in the entire experiment, across all six libraries and 2.4 million identifiers, was a cross-process pair. Not a single one came from within a worker.
Half of 100% is not a coincidence, it is the arithmetic. Two identifiers created inside the same millisecond by two processes share a timestamp field, so the comparison falls through to the random tail, and two independent random numbers sort correctly exactly half the time. The measured figures are 49.84 to 50.01: the information content of a cross-process v7 comparison, inside a millisecond, is zero bits.
The median real-time gap between an inverted pair was three microseconds. This is not a long-tail artefact that disappears under light load. It is the resolution floor, and every write your application makes during a busy millisecond sits on it.
If you are using v7 purely to keep index pages warm, none of this matters: a millisecond of jitter still clusters writes. If you are using ORDER BY id as a cheap substitute for ORDER BY created_at, you are sorting a third of your rows by coin flip.
Finding 3: the two that work are implementing a draft the RFC rejected
Two libraries escaped the coin flip almost entirely: uuid7 0.1.0 at 0.94% and uuid-v7 1.0.0 at 1.44%. We went looking for why, and decoded their timestamp fields.
Both read as 12 September 2202.
RFC 9562 section 5.7 is unambiguous about what those 48 bits are: “the number of milliseconds since midnight 1 Jan 1970 UTC, leap seconds excluded”. These two libraries put something else there. We checked the arithmetic: their top 48 bits are the Unix time in seconds, shifted left by 12, with the first 12 bits of a 24-bit sub-second fraction filling the gap. Divide the field by 4,096 and you get the current second back exactly, to the integer.
That is not a bug, it is a fossil. uuid7 0.1.0 says so in its own docstring: it follows draft-peabody-dispatch-new-uuid-format-02, the 2021 proposal whose layout was 36 bits of whole seconds followed by sub-second fields. We pulled the draft to confirm, and the diagram is there on line 548. The working group later replaced it with the flat 48-bit millisecond field that shipped in the RFC. uuid-v7 1.0.0 is a fork of an early uuid6 release carrying the same layout, still on PyPI, still being installed.
The 24-bit fraction gives roughly 60 nanoseconds of resolution, which is why two processes rarely tie, which is why these two libraries are the only two that order correctly. The implementations that violate the specification are the ones that deliver the property everyone adopts the specification for.
Between them, those two packages are downloaded 11.2 million times a month. If anything in your stack calls Postgres uuid_extract_timestamp(), or any other decoder that implements section 5.7 as written, it will hand you the twenty-third century and no error.
Finding 4: a timestamp that runs away with no clock change at all
uuid6 2025.0.1, 10.9 million downloads a month, takes a different approach. Its monotonicity logic is three lines: if the current millisecond is not greater than the last one used, add one to the last one and use that. There is no counter field at all. The timestamp is the counter.
In our single-process run it generated 200,000 identifiers in 1.475 seconds, and every one got its own distinct millisecond value. The median identifier was stamped 100.3 seconds in the future, the last one 199.5 seconds ahead. With the clock frozen, 10,000 identifiers moved the embedded timestamp forward by 9,999 milliseconds: ten seconds of timestamp space consumed inside one millisecond of real time.
Recovery is one millisecond of drift per millisecond of idleness, so a service that burns two hundred seconds of timestamp during a traffic spike needs two hundred seconds of quiet to resynchronise. The published article demonstrated this runaway on the Python standard library by moving the system clock backwards. uuid6 gets there on an ordinary busy afternoon.
And RFC 9562 permits it. Section 6.1 says plainly that “Implementations MAY alter the actual timestamp” and that “This specification makes no requirement or guarantee about how close the clock value needs to be to the actual time”. Compliant, and a hundred seconds wrong.
Finding 5: the 4,096 cliff
@kripod/uuidv7 0.3.4 is the only library in our set implementing the specification’s Method 1, a dedicated 12-bit counter in the rand_a field. We confirmed that empirically before reading a line of source: the counter increments by exactly one on all 198,525 same-millisecond pairs.
Twelve bits holds 4,096. We froze the clock and asked for 20,000 identifiers in a single millisecond. The first 4,096 were valid. The remaining 15,904 were 37 characters long, and every one of them was rejected by npm uuid‘s own validate() and by Python’s uuid.UUID() constructor.
The cause is one missing mask. The counter is a plain JavaScript number with no wraparound, rendered with seq.toString(16).padStart(3, "0"). At 4,096 the hex representation becomes four digits, padStart(3) does nothing, the concatenated string gains a character, and the fixed-offset slicing that inserts the hyphens shifts everything right. The output stops being a UUID.
A frozen clock is artificial, so we ran the realistic trigger. The library deliberately ignores backwards clock adjustments, pinning its timestamp with Math.max(Date.now(), prevTimestamp). We stepped the clock back five seconds mid-run, exactly what an NTP correction, a VM live migration or a container clock reset does. The embedded timestamp correctly refused to go backwards. It also pinned the counter, and 15,923 of the next 20,000 identifiers came out malformed, the first on identifier 4,077. The two other JavaScript libraries sailed through the same step with zero malformed output and zero inversions.
This one is not a specification grey area. RFC 9562 section 6.2 says “Counter rollovers MUST be handled by the application to avoid sorting issues”, and offers two acceptable responses: freeze the counter and wait for the clock, or roll the timestamp forward and reseed. Emitting a 37-character string is neither.
Perspective on blast radius: @kripod/uuidv7 is downloaded 14,894 times a month, not 14 million. The bug has been live since the last release on 3 July 2022, and in seven issues ever filed nobody has hit it. The single open issue, from May 2024, is titled “tests seem to be missing”. That is the finding, really. A library can carry a four-year-old defect that destroys its output format and still look healthy from the outside, because the thing that would have caught it was never written.
Finding 6: “unguessable” is relative to the identifier next to it
The third claim made for v7 is that the 74 non-timestamp bits make identifiers unguessable. True in isolation, much weaker in sequence, because of how the specification’s Method 2 achieves monotonicity: take the random tail and add a random increment, rather than rerolling it.
We measured the largest increment each library ever applied between two identifiers in the same millisecond, and counted how many of the 74 bits two consecutive identifiers share.
| Implementation | Largest observed increment | Mean shared bits (of 74) | Worst observed pair |
|---|---|---|---|
Python 3.14 uuid.uuid7() |
2^33 | 56.0 | 68 shared |
uuid-utils 1.1.0 |
2^33 | 56.0 | 69 shared |
npm uuidv7 1.2.1 |
2^33 | 56.0 | 69 shared |
fastuuid 0.14.0 |
2^37 | 54.0 | 67 shared |
npm uuid 14.0.3 |
2^43 | 51.0 | 64 shared |
@kripod/uuidv7 0.3.4 |
2^63 | 41.0 | 58 shared |
Read the first row as: if you hold one identifier from a Python 3.14 process and want the next one it issued in that same millisecond, you are not searching 74 bits of space. You are searching 33, and on average 56 of the 74 bits are already fixed. In the worst pair we observed, five bits were unknown.
Whether that matters depends on whether an attacker can obtain one identifier and needs an adjacent one. For a password reset token, that is the whole attack. For a row id in a URL, it is a reason not to treat v7 as a capability. The RFC is explicit that this is a knob and not an accident: section 6.2 warns that incrementing by one “SHOULD NOT be used by implementations that favor unguessability”.
Notice the shape of the table. The library with the largest increment and the most fresh entropy is the one with the 4,096 cliff. The two libraries not in this table at all, the draft-02 fossils, reroll all 74 bits and lead on cross-process ordering. There is no implementation here that leads on everything, because the bits are finite and these are the same bits. Every library made a trade. Not one README names it.
What to actually do
Adopt v7. The index locality argument is real and it is why the industry moved. But be specific about which of the three promises you are leaning on, because they are not delivered together.
Decode one identifier from production before you trust the timestamp. One line, thirty seconds. If it does not read as a plausible date, your library is not storing Unix milliseconds, and anything downstream that decodes it is wrong and silent.
If you need real cross-process ordering, add a column. A microsecond timestamptz and an index is boring, correct, and does not depend on which monotonicity method your package manager resolved last Tuesday. We have written before about how easily an ORM’s timestamp defaults invert their own meaning: the default is a decision someone else made for you.
Test what you depend on, not what is easy to test. The single-process sorted-loop assertion passes on all nine of these libraries, including the one that emits 37-character strings. A test that spawns four workers and merges them would have failed on seven of nine. It is twenty lines.
This is the kind of thing we end up doing on client work: a property everyone assumes is settled, nobody has measured, and a two-hour experiment either confirms or demolishes. If your identifiers, your migrations or your multi-tenant isolation model rest on assumptions nobody has checked recently, our development and DevOps teams are good at checking them. Get in touch.

