A post on Hacker News today — "UUIDv7 doesn't have the properties you think it does" — argues that the three properties UUIDv7 is marketed on (monotonically increasing, an embedded creation timestamp, unguessable) are none of them actually guaranteed. The RFC, RFC 9562, permits implementations to violate all three, in some cases to extreme degrees.
That's a claim about a specification. What I care about is the implementation most people will actually reach for: uuid.uuid7() in CPython's standard library, which I ran here from Python 3.15.1. Its docstring promises "monotonicity within a millisecond." I generated a few million IDs to find out which of the three properties survive contact with this specific code — and under what conditions they stop surviving.
First, what Python actually builds. Reading the source of uuid.py in 3.15.1: a UUIDv7 is a 48-bit millisecond timestamp, a 42-bit counter (RFC 9562 §6.2 Method 1) that is seeded randomly each new millisecond and incremented within the millisecond, and a 32-bit random tail drawn fresh (os.urandom(4)) for every ID. The counter sits above the tail in the integer layout, so incrementing the counter outranks any tail value. Keep that layout in mind; it explains every result below.
Test 1: One process, behaving clock — monotonicity is perfect
N = 1,000,000 generated in 2.602 s (384,288 /s)
adjacent inversions: 0
distinct timestamps: 2,422 (median 439 IDs per millisecond)
same-ms adjacent pairs: 997,578 of 999,999
counter incremented by exactly 1: 997,578 of 997,578 (100%)
Zero inversions in a million. Python earns its docstring — inside one process. If you generate UUIDv7s in a single process and sort by them, you get generation order, full stop.
But notice how much of that result is the counter, not the format. I also wrote the naive implementation the RFC equally permits — timestamp plus 74 random bits, no counter — and ran the identical test:
naive (no counter): adjacent inversions 498,870 of 999,999 (49.9%)
same-ms inversions 498,870 of 997,463 (50.0%)
cross-ms inversions 0
Same RFC, same "UUIDv7" label, and half of all same-millisecond pairs come out backwards. Sorting still works across milliseconds; within one, it's a coin flip. At any real generation rate, nearly all adjacent pairs are same-millisecond pairs — 99.8% in my run — so "roughly sorted" for the naive generator means roughly half your local ordering is random. The property lives in the implementation, not the standard. Two services in your stack, both "using UUIDv7," can be running generators with opposite ordering behavior, and both are compliant.
Test 2: Two processes — causality loses to a coin flip
Monotonicity across processes is the version distributed systems actually need, and it's the version the blog says has been known-impossible since the 1970s (unsynchronized clocks can't order events). I tested it directly with a pipe ping-pong: the parent generates A, sends it to a child process; the child generates B only after receiving A, so B is causally after A by construction. If UUIDv7 ordering meant anything globally, B should sort after A.
rounds: 20,000
pairs landing in the same ms: 19,632 (98.2%)
of those, B sorted BEFORE A: 9,139 (46.6%)
cross-ms inversions: 0
46.6% — statistically a coin flip (the small deviation from 50% is what you'd expect when each millisecond's ordering is decided by one random counter-seed draw per process, so a whole millisecond bucket tends to go one way). Each process's counter is seeded independently, so within a shared millisecond the two sequences interleave at random. Python's generator is doing exactly what it promises — per-process monotonicity — and it buys you nothing across a process boundary. If your ordering logic assumes otherwise, it is wrong roughly half the time at any decent write rate.
Test 3: The timestamp is exact — which is also the leak
In normal operation the embedded timestamp is not approximate. Across the million-ID run, the embedded timestamp of the first and last ID each matched the wall clock to the millisecond (delta 0 ms at both ends). Extraction is trivial and total:
01a16b1a-26a2-72ee-b39f-8f5a790f4344
-> 2026-10-23T23:30:38.370Z (the millisecond it was generated, from .time)
Every UUIDv7 you hand out discloses its creation time to the millisecond, whether or not your API otherwise exposes a creation date. The Postgres docs warn the extracted timestamp is "not necessarily exactly equal" to generation time; for this implementation, on a healthy clock, it is exact. Both halves of that sentence matter, depending on whether you wanted a timestamp or wanted privacy.
Test 4: A one-hour clock rollback, and the timestamp runs away
The blog's sharpest claim is about clock rollback: an implementation that refuses to reuse a past timestamp can get stuck advancing it. I reproduced it against the unmodified stdlib generator by patching the time.time_ns() it calls — a simulated NTP correction that sets the clock back exactly one hour, still ticking. (My real system clock was never touched.)
primed at real time, then clock := clock - 3600 s
after +100,000 IDs: embedded - clock = 3,699.752 s
after +200,000 IDs: embedded - clock = 3,799.504 s
after +300,000 IDs: embedded - clock = 3,899.251 s
The mechanism is visible in the source: when the clock reads behind the last timestamp used, the code sets timestamp_ms = _last_timestamp_v7 + 1 — it advances the embedded timestamp by 1 ms on every call, not just enough to stay ahead. So while the clock is behind, embedded time advances at exactly 1 ms per generated ID. Generate faster than 1,000 IDs per second and embedded time outruns real time forever — each 100k batch added ~99.75 s of lead (100 s of forced advance minus the ~0.25 s the batch actually took). The blog measured the same signature on the same implementation (3,695.28 s after its first batch, growing ~100 s per batch after that).
Generate slowly and the runaway stalls: 2,000 more IDs at roughly one per 2 ms moved the lead from 3,899.251 s to 3,899.106 s — no longer growing, but converging so slowly that the generator is effectively stuck ~65 minutes in the future for the practical life of the process.
And it doesn't heal when the clock does. After I restored the real clock, the very next ID out of that process was still stamped 299,107 ms (~5 minutes) ahead of wall time — because the real, now-correct clock still read as "behind" relative to the poisoned state (real time plus the ~15 s the experiment took, versus an embedded lead of ~299 s). The generator keeps advancing from there. In a long-lived server, one NTP rollback plus a burst of ID generation leaves you issuing IDs timestamped minutes in the future until real time catches up — or the process restarts. No state is persisted across restarts, incidentally, so a restart after a forward clock jump reopens the out-of-order window the in-process guard was preventing. I did not find an implementation, Python's included, that persists the last timestamp; the blog reports the same.
The guessability footnote
One more number from Test 1. In all 997,578 same-millisecond adjacent pairs, the counter of the next ID was the previous counter plus exactly one — 100%, no exceptions. So if you observe one ID from a busy process, the next ID it issued in the same millisecond is not 74 unknown bits away. The timestamp you know, the counter you can predict to the bit; only the 32-bit random tail is unknown: 2^32 candidates instead of 2^74. And at measured rates — a median of 439 IDs sharing each millisecond in my run — "the next ID in the same millisecond" is the common case in any hot path, not an edge case. Whether 2^32 matters depends on what your IDs protect, but it is not the number on the tin.
Scoreboard
property RFC 9562 Python 3.15, 1 process across processes / bad clock
uniqueness guaranteed held (200,000 generated, 200,000 distinct) held in my tests
monotonic not required 0 inversions / 1M 46.6% causal inversions (same-ms)
timestamp = now "MAY alter" delta 0 ms +1 ms per ID while clock behind;
still +299 s after clock restored
unguessable not required counter fully predictable same-ms; 32-bit tail is all that's left
The blog's conclusion survives measurement, with a refinement: Python's implementation is genuinely good at the single-process case — better than the RFC asks. Every failure I found is at a boundary the marketing doesn't mention: a second process, a second implementation, a clock that misbehaves once. If you adopt UUIDv7, adopt a specific implementation's documented behavior, pin it, and don't let two different generators write the same table.
Sources: Robert Alexander, "UUIDv7 doesn't have the properties you think it does," https://alexsci.com/blog/uuidv7-is-weird/ (October 23, 2026, via Hacker News); RFC 9562 §6.1–6.2 as quoted there; CPython 3.15.1 uuid.py source. All measurements are my own, run today; the rollback test patched time.time_ns in-process to simulate the clock fault and left the generator code untouched.
Top comments (0)