The advice usually arrives inside a code review, in a helpful tone: if you want IDs that sort by time, use v1 — that's the time-based one. Half of that is true. UUIDs go back to the Apollo Network Computing System, and version 1 has been built around a clock since the spec first named versions. It is still not sortable by time. That gap is why the IETF published a new UUID version in 2024 with the sole purpose of putting v1's own fields back in a sensible order.
This guide walks RFC 9562 as written, decodes real identifiers by hand, and runs our own generator against both halves of the claim. Everything below was produced on 25 July 2026 in throwaway Docker containers — node:22-alpine, python:3.12-slim, postgres:18-alpine, mysql:8.0 — against the tool code committed at 552394ef. We measured index behaviour too, and are not publishing those numbers: one unrepeated run on a laptop is not a benchmark.
Obsoletes: 4122The document most UUID advice still cites was replaced in 2024
RFC 4122 — P. Leach, M. Mealling and R. Salz, July 2005 — is what most blog posts and half the library READMEs point at. It is obsolete. RFC 9562, by K. Davis, B. Peabody and P. Leach, arrived in May 2024 on the Standards Track, its header carrying the line Obsoletes: 4122. Section 1 says it outright: "This document obsoletes RFC 4122."
Section 2.1 lists what prompted the rewrite, and it reads like complaints from people running databases. Item one: "UUID versions that are not time ordered, such as UUIDv4 (described in Section 5.4), have poor database-index locality." Item three is the one this guide is about:
"Introspection/parsing is required to order by time sequence, as opposed to being able to perform a simple byte-by-byte comparison."
That is not a complaint about v4. v4 has no time to order by. It is aimed at the versions that do carry a timestamp and still cannot be sorted with a plain byte comparison — v1 above all.
Where a v1 keeps its clock
A UUIDv1 timestamp, per RFC 9562 §5.1, is a 60-bit count of 100-nanosecond intervals since 15 October 1582. That number is then chopped into three fields and written out of order: the low 32 bits first as time_low, the middle 16 as time_mid, the high bits last beside the version nibble. The first group in the text form — eight characters, before the first hyphen — is the least significant slice of the clock.
This follows from the arithmetic of the specification itself, not from opinion. The field holds 2³² = 4,294,967,296 tick values, each 100 nanoseconds, so one full cycle of the leading group is 4,294,967,296 × 100 ns = 429.4967296 seconds — 429.4967 s, or 7.158 minutes. Roughly every seven minutes those eight characters run off the end of their range and restart from the bottom while the clock they came from keeps climbing.
Our own generator settles it without argument. The probe prints the function's source first, then runs it against a shadowed clock:
docker run --rm -v $PWD/scripts:/s:ro -v /Users/jeffreytan/Projects/recatools/public/tools/uuid-generator/app.js:/tool/app.js:ro node:22-alpine node /s/07-tool-defects.mjs
…
Two v1s, generated 8 minutes apart on the shadowed clock:
t+0min : 66195b50-8813-11f1-bd25-d1bf530fc204
t+8min : 84338b50-8814-11f1-bdc1-b56911df82a8
a1 < a2 (i.e. sorts into creation order) ? true
60 v1s, one per minute over one hour — how many land out of order?
v1: 59 of 60 positions differ from sorted order
v7: 0 of 60 positions differ from sorted order
first 6 v1s, one minute apart (watch the leading group):
+0min 66195b50-8813-11f1-84ed-652fa2e81c86
+1min 89dca150-8813-11f1-a678-c7999a7c80d3
+2min ad9fe750-8813-11f1-a322-c78c88129014
+3min d1632d50-8813-11f1-b0b7-4509c66b2a57
+4min f5267350-8813-11f1-b1f4-0dbe09570ed6
+5min 18e9b950-8814-11f1-9f65-dd442c47ee47
why: the first group of a v1 is time_low, the LEAST significant 32 bits
of a 100-ns counter. It wraps every 2^32 x 100ns = 429.4967 s = 7.158 minutes.
…Read the six leading groups in order: 66195b50, 89dca150, ad9fe750, d1632d50, f5267350, then 18e9b950. Four steady climbs and a cliff. Nothing went wrong in the fifth minute.
The first result in the transcript shows why this belief survives. Two v1s eight minutes apart sorted into creation order — true. A wrap does not guarantee an inversion; it guarantees only that the leading group carries no reliable information about which value is older. Any two samples have a decent chance of coming out right, so a quick check in a console tends to confirm what you already believed. Sixty of them over an hour do not.
Rather than try to explain the field order, the IETF working group defined a new version that fixes it. RFC 9562 §5.6: "UUIDv6 is a field-compatible version of UUIDv1 (Section 5.1), reordered for improved DB locality. … Instead of splitting the timestamp into the low, mid, and high sections from UUIDv1, UUIDv6 changes this sequence so timestamp bytes are stored from most to least significant." Then §5.7 settles it for new work: "Implementations SHOULD utilize UUIDv7 instead of UUIDv1 and UUIDv6 if possible."
128 bits, two ways
Set a v4 and a v7 side by side and the family resemblance is total. Both are 128 bits. Both surrender 4 bits to the version field at bits 48 through 51 and 2 bits to the variant at bits 64 and 65, leaving 122 bits for everything else. The layouts differ in exactly one place.
In a v4, bits 0–47 are random_a, bits 52–63 are random_b and bits 66–127 are random_c; the spec's summary is one line, "UUIDv4 is meant for generating UUIDs from truly random or pseudorandom numbers." In a v7 those same positions hold unix_ts_ms, rand_a and rand_b, and §5.7 describes the first as a "48-bit big-endian unsigned number of the Unix Epoch timestamp in milliseconds". Forty-eight bits at the front are moved from noise to a clock. That’s the entire change, and it’s what makes a byte-by-byte comparison meaningful.
Two hex digits are worth learning to read. The RFC writes the canonical shape as xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx: M is the version, N carries the variant — the 13th and 17th hex digits. Across 1,000 samples from our generator the version nibble was 4 every time and the variant nibble only ever 8, 9, a or b — binary 10xx, as §4.1 requires. If reading a nibble as bits is not yet automatic, our number base converter turns one hex digit into its four bits and back.
You need not take our decoder's word for the timestamp either. PostgreSQL 18 ships uuid_extract_version() and uuid_extract_timestamp(), an implementation nobody on this side wrote. We piped six v7s and three v4s straight from the tool into a container and asked:
docker exec -i w20pg psql -U postgres < /tmp/tool.sql
…
n | kind | id | ver | decoded_ts
---+------+--------------------------------------+-----+----------------------------
1 | v7 | 019f98d3-776a-79c2-b0ef-7fb7856601ce | 7 | 2026-07-25 10:30:23.338+00
2 | v7 | 019f98d3-7801-748f-bfa2-923118f22759 | 7 | 2026-07-25 10:30:23.489+00
3 | v7 | 019f98d3-789a-78c4-9e38-54f6d00fcf15 | 7 | 2026-07-25 10:30:23.642+00
4 | v7 | 019f98d3-7931-769e-b949-347920c21f2a | 7 | 2026-07-25 10:30:23.793+00
5 | v7 | 019f98d3-79c8-7c6a-a082-2d89050c22d1 | 7 | 2026-07-25 10:30:23.944+00
6 | v7 | 019f98d3-7a5f-787c-9ad7-93846c337d40 | 7 | 2026-07-25 10:30:24.095+00
7 | v4 | 9222350e-09c2-4d64-81e9-8674f315bcdf | 4 |
8 | v4 | 74886398-f7c8-4534-aade-dc2d2121a1a7 | 4 |
9 | v4 | c01d6589-f8b1-4fe5-8732-1fc8737b7a8e | 4 |
(9 rows)
…The empty column on the v4 rows is documented behaviour: uuid_extract_timestamp "extracts a timestamp with time zone from a UUID of version 1 or 7. For other versions, this function returns null." Asked to sort those rows by value, the database returned the six v7s as 1, 2, 3, 4, 5, 6 — and the three v4s as 8, 7, 9. The field's ceiling is distant, per §6.1: "UUIDv1 and UUIDv6 utilize a 60-bit timestamp valid until 5623 AD; UUIDv7 features a 48-bit timestamp valid until the year 10889 AD."
The collision number, derived rather than repeated
The most-quoted sentence about UUIDs is some version of this: generate a billion every second for a hundred years and you still won't see a duplicate — usually with "less than a 50% chance" attached. Our own tool page carried exactly that line until today. That claim is wrong, and the correction is a first-year probability exercise.
docker run --rm -v $PWD/scripts:/s:ro python:3.12-slim python /s/08-birthday.py
Free bits in a UUIDv4 : 128 - 4 (ver) - 2 (var) = 122
Size of the value space N : 2**122
= 5316911983139663491615228241121378304
~ 5.3169e+36
--- The 50%% point ---
n50 = sqrt(2 * N * ln2) = 2.7149e+18 (~2.71 x 10^18)
Sanity check, P(n50) = 0.500000The relevant question is not whether two specific UUIDs match — that is 1/N = 1.8808 × 10⁻³⁷, one in 5.317 × 10³⁶, and it is the number people accidentally quote. It is whether any two in a population match: the birthday problem, P = 1 − exp(−n(n−1)/2N). A billion per second for 100 Julian years is n = 10⁹ × 100 × 365.25 × 86,400 = 3.15576 × 10¹⁸ identifiers against N = 5.3169 × 10³⁶. Same run, further down the output:
n at 1e9/s for 100 years (Julian, 365.25d) = 3.155760e+18
n^2/(2N) (the page's own formula, blade:324) = 0.936523
P = 1 - exp(-n(n-1)/2N) = 0.608012 -> 60.80%
n / n50 = 1.162
=> the probability is ABOVE 50%%, not below.
Using a flat 365-day year instead: n = 3.153600e+18, P = 0.6075 (60.75%) — still above 50%.
--- What IS true at 1e9 UUIDs/second ---
Years at 1e9/s to reach the 50% point: 86.03 years60.80%, not "less than 50". The coin-flip point arrives earlier than the folklore allows, at n₅₀ = √(2N ln 2) = 2.7149 × 10¹⁸ values — 86.03 years at a billion a second. The popular sentence overshoots that mark and then reports the answer backwards. None of which makes v4 collisions a practical worry, and the honest framing is the small numbers rather than the theatrical one:
docker run --rm -v $PWD/scripts:/s:ro python:3.12-slim python /s/08b-smalln.py
N = 2**122 = 5316911983139663491615228241121378304
n x = n(n-1)/(2N) ~ P(at least one collision)
…
1e9 9.403955e-20
…
1e12 9.403955e-14
…
1e15 9.403955e-08
…
1e18 9.403955e-02A trillion v4s carry a collision probability around 9.4 × 10⁻¹⁴. That is the sentence worth repeating, because it has the advantage of being true.
What a B-tree does with a random key
The reason anyone cares which version leads with a clock is the index, and the mechanism is documented by the people who wrote the index. PostgreSQL, on the fillfactor storage parameter for B-trees:
"Controls how full the index method will try to pack index pages. For B-trees, leaf pages are filled to this percentage during initial index builds, and also when extending the index at the right (adding new largest key values). If pages subsequently become completely full, they will be split, leading to fragmentation of the on-disk index structure."
The middle clause describes two different behaviours for the index. "Extending the index at the right — adding new largest key values" is what a time-ordered key does on every insert: each value is larger than the last, so it lands at the growing edge and pages fill the way the parameter anticipates. A random key has no growing edge. Every insert lands wherever its bits say, in a page already as full as the fillfactor left it, and full pages split. That is the RFC's "poor database-index locality", in the vendor's own vocabulary.
RFC 9562 §6.11 makes the same case from the identifier's side: v6 and v7 are designed so that implementations needing sorting "sort as opaque raw bytes without the need for parsing or introspection", and time-ordered values "benefit from greater database-index locality because the new values are near each other in the index". Then it adds a sentence to handle carefully: "The real-world differences in this approach of index locality versus random data inserts can be one order of magnitude or more."
That line is a direct quotation from RFC 9562. The RFC publishes no measurement behind it, names no workload and defines no baseline, so read it as the working group's summary judgement rather than a figure for a capacity plan. Anyone repeating it as "v7 is ten times faster" has invented a benchmark that does not exist.
One line from the spec costs nothing to act on. §6.13: "For many applications, such as databases, storing UUIDs as text is unnecessarily verbose, requiring 288 bits to represent 128-bit UUID values. Thus, where feasible, UUIDs SHOULD be stored within database applications as the underlying 128-bit binary value."
What v7 does not promise
Sorting *by* the millisecond is not the same as sorting *within* a given millisecond. RFC 9562 §6.2 makes the finer guarantee optional: "Normally, time-based UUIDs from this document will be monotonic due to an embedded timestamp; however, implementations can guarantee additional monotonicity via the concepts covered in this section." Three methods follow — a dedicated counter, monotonic random, or extra clock precision in place of the leftmost random bits — and §5.7 lists all three as OPTIONAL. Our generator implements none, which is spec-legal and easy to demonstrate:
docker run --rm -v $PWD/scripts:/s:ro -v /Users/jeffreytan/Projects/recatools/public/tools/uuid-generator/app.js:/tool/app.js:ro node:22-alpine node /s/03-sortability.mjs
…
UUID v7 (2 ms apart): 200 ids, 0 of 200 positions differ between creation order and sorted order
UUID v4 (2 ms apart): 200 ids, 198 of 200 positions differ between creation order and sorted order
### The honest caveat: same-millisecond generation
8 v7s generated with no delay (all inside one millisecond tick):
1 019f98cd-cc69-727d-a2da-75a7054b393c
2 019f98cd-cc69-7a6f-bbab-388ee06399d3
3 019f98cd-cc6a-790b-b5dc-ec4b824284f2
4 019f98cd-cc6a-7e0a-8c8e-1efaad77b9e7
5 019f98cd-cc6a-783e-ae64-9296e728a3c7
6 019f98cd-cc6a-7545-90d0-23e1c51da764
7 019f98cd-cc6a-7437-86e1-46b742effc09
8 019f98cd-cc6a-7258-a52b-c570c6fa6d5a
first 48 bits (timestamp) identical for all 8 ? false
sorted order == creation order ? false
-> Inside a single millisecond this implementation fills the remaining
74 bits with pure randomness, so ordering within that millisecond is
random. RFC 9562 makes intra-millisecond monotonicity OPTIONAL (Sec 6.2).
…Read that run honestly: the line announcing the samples says one millisecond tick, and the check three lines below says false, because ids 1 and 2 carry cc69 and ids 3 to 8 carry cc6a — the batch straddled a tick boundary. The demonstration survives it. The six ids sharing cc6a were generated inside a single millisecond and still came out unsorted, which is the whole point.
Node's documentation carries the same warning for its built-in: crypto.randomUUIDv7(), added in v26.1.0, generates an RFC 9562 version 7 UUID whose "embedded timestamp relies on a non-monotonic clock and is not guaranteed to be strictly increasing." Clocks get adjusted. Batches get generated in tight loops. Ordering finer than a millisecond is a property you implement, not one you inherit.
Neither version gives you secrecy. §8 is direct: "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)." It also notes that "timestamps embedded in the UUID do pose a very small attack surface" — a v7 tells its holder roughly when the row was created — and that where a UUID must touch a security operation, v4 from a CSPRNG is the version to use.
What your database already gives you in 2026
PostgreSQL 18 generates both versions natively — its manual states that "PostgreSQL provides native support for generating UUIDs using the UUIDv4 and UUIDv7 algorithms", and describes the uuid type itself as storing identifiers "as defined by RFC 9562, ISO/IEC 9834-8:2005, and related standards". Its uuidv7() computes the timestamp using "UNIX timestamp with millisecond precision + sub-millisecond timestamp + random" — the third monotonicity method from §6.2, implemented for you.
# From the PostgreSQL 18 manual — signatures, NOT run in this session
uuidv4 ( ) → uuid -- also spelled gen_random_uuid()
uuidv7 ( [ shift interval ] ) → uuid -- version 7, time-ordered
uuid_extract_version ( uuid ) → smallint
uuid_extract_timestamp ( uuid ) → timestamp with time zone
MySQL 8.0 is a different story; we tested 8.0.46 specifically. It has no UUID column type at all — CREATE TABLE probe (id UUID) returns ERROR 1064, a syntax error — and its UUID() function returns a version 1 value. Its UUID_TO_BIN() swap flag, passed around as a general UUID optimisation, is documented as nothing of the sort. Swapping "moves the more rapidly varying part to the right and can improve indexing efficiency if the result is stored in an indexed column", says the server's own bundled help text — followed immediately by the sentence nobody quotes: "Time-part swapping assumes the use of UUID version 1 values, such as are generated by the UUID() function. For UUID values produced by other means that do not follow version 1 format, time-part swapping provides no benefit."
Node.js has had crypto.randomUUID() since v15.6.0 and v14.17.0, and crypto.randomUUIDv7() since v26.1.0. ULID deserves a note about status: not a standards-track document but a project README, describing a 48-bit millisecond timestamp plus 80 bits from a "cryptographically secure source of randomness, if possible", in Crockford's Base32 — an alphabet that "excludes the letters I, L, O, and U to avoid confusion and abuse".
| UUIDv4 | UUIDv7 | UUIDv1 | ULID | |
|---|---|---|---|---|
| Leading field | random_a, 48 random bits | unix_ts_ms, 48-bit Unix ms | time_low, low 32 bits of a 100-ns counter | 48-bit Unix ms |
| Sorts by creation time on a raw byte comparison | No | Yes, across milliseconds | No | Yes, across milliseconds |
| Creation time recoverable from the value | No | Yes | Yes, with introspection | Yes |
| Defined by | RFC 9562 §5.4 | RFC 9562 §5.7 | RFC 9562 §5.1 | project README, not a standards document |
Our own generator, and what we fixed today
Our UUID generator produces v4, v7, v5 and v1, plus ULID and NanoID, entirely in the browser. Every random byte comes from the Web Crypto API — crypto.randomUUID() where the browser offers it, crypto.getRandomValues() everywhere else; the string Math.random appears nowhere in the file. The version and variant bits are set as §4.2 and §4.1 require in every version it emits, its v5 reproduces the RFC's own DNS test vector exactly, and its v7 carries a genuine 48-bit big-endian Unix millisecond timestamp — the one PostgreSQL decoded above without being told what it was looking at.
We found several errors in our own code; two are worth mentioning. the NanoID generator returned the same 21-character string on every call, for a reason that is invisible in review — the output buffer was allocated and consumed before it was ever filled. new Uint8Array(34) is zero-filled by specification, the in-loop refill sat behind a test that could not be true on entry, and with a 64-character alphabet a 6-bit mask rejects nothing, so that guard was never reached later either. Every character came from an untouched zero byte. The fix fills the buffer before the loop; 1,000 calls now return 1,000 distinct values.
The second was this guide's own subject. The page previously recommended v1 when you wanted identifiers that sort by creation time — the exact error corrected above, in our own words, on our own site. It now leads with v7 for that job, states the time_low wrap and quotes §5.7. The pull quote claiming a 100-year collision chance "less than 50%" is gone too; the arithmetic in this guide is the arithmetic on the page.
FAQ
Should I pass MySQL's swap flag when storing v7 values?
No. MySQL's own documentation says time-part swapping "assumes the use of UUID version 1 values" and that for values which do not follow version 1 format it "provides no benefit". Applied to a v7 it is worse than useless: it moves the timestamp out of the leading bytes, which is where you wanted it.
Can I use UUIDs as unguessable access tokens?
No, and the RFC says so in a MUST NOT. Per §8, they "MUST NOT be used as security capabilities (identifiers whose mere possession grants access)". If a UUID has to touch a security operation at all, §8 points at v4 from a CSPRNG — and even then it is an identifier, not a credential.
- Davis, Peabody & Leach, "Universally Unique IDentifiers (UUIDs)" — RFC 9562, May 2024 (Standards Track; Obsoletes 4122) (accessed 25 Jul 2026)
- Leach, Mealling & Salz — RFC 4122, July 2005 (obsoleted by RFC 9562) (accessed 25 Jul 2026)
- CREATE INDEX — storage parameter
fillfactor, PostgreSQL 18 manual (accessed 25 Jul 2026) - 8.12. UUID Type — PostgreSQL 18 manual (accessed 25 Jul 2026)
- 9.14. UUID Functions —
uuidv7,uuid_extract_timestamp, PostgreSQL 18 manual (accessed 25 Jul 2026) - UUID_TO_BIN — MySQL 8.0 reference manual (text read from the server's own bundled help tables in mysql:8.0.46; dev.mysql.com was unreachable from our network that day) (accessed 25 Jul 2026)
- ULID specification — project README,
ulid/spec(master branch) (accessed 25 Jul 2026)