The UUID Regex Everyone Copies Rejects UUID v7

There is a UUID regex that appears at the top of every Stack Overflow answer on the subject, in a hundred blog posts, and — I would bet — in your codebase:

/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i

It is presented as the strict, correct one, the upgrade from the naive "32 hex digits with hyphens" version. It checks the version nibble. It checks the variant nibble. It looks like somebody read the spec.

Somebody did read the spec. They read RFC 4122, which was obsoleted in May 2024.

That [1-5] rejects UUID v7. If you switch your primary keys to v7 for the index locality — which is the whole reason anyone switches — this regex starts rejecting the identifiers your own application generates, at whatever layer you put it. I wanted to know exactly how far the damage spreads, so I ran it against eleven inputs alongside the loose version and a corrected one.

What each validator accepts

Input Loose regex The [1-5] regex RFC 9562 Python uuid.UUID()
v4 accept accept accept accept
v7 accept reject accept accept
v8 accept reject accept accept
nil UUID accept reject accept accept
max UUID accept reject accept accept
v4, wrong variant accept reject reject accept
version 0 accept reject reject accept
v4 uppercase accept accept accept accept
{braces} reject reject reject accept
no hyphens reject reject reject accept
urn:uuid: prefix reject reject reject accept

Three different tools, three different definitions of "is this a UUID", and none of them is the one you probably want.

Why [1-5] is wrong now

RFC 9562 landed in May 2024 and replaced RFC 4122 outright. It kept versions 1 through 5, and it standardised three more that had been circulating as drafts for years:

  • v6 — v1 with the timestamp bits rearranged so it sorts chronologically
  • v7 — Unix millisecond timestamp plus random, the one people actually adopt
  • v8 — deliberately unspecified layout, reserved for custom schemes

A version-range check written against the old RFC caps at 5 and silently excludes all three. The failure mode is nasty because it is delayed: the regex sits in a request validator or a route constraint, passes every test you wrote with v4 fixtures, and only breaks when someone changes the generator. At that point the errors point at the new IDs, not at the eight-year-old regex, and you spend an hour looking in the wrong place.

You can watch it happen in about fifteen lines:

import { randomUUID } from 'node:crypto';

const STRICT = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const RFC9562 = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

// A v7: 48-bit millisecond timestamp, version nibble 7, variant nibble 8-b.
function uuidv7() {
    const b = new Uint8Array(16);
    crypto.getRandomValues(b);
    const ms = BigInt(Date.now());
    for (let i = 0; i < 6; i++) b[i] = Number((ms >> BigInt(40 - 8 * i)) & 0xffn);
    b[6] = (b[6] & 0x0f) | 0x70;
    b[8] = (b[8] & 0x3f) | 0x80;
    const h = [...b].map(x => x.toString(16).padStart(2, '0')).join('');
    return `${h.slice(0,8)}-${h.slice(8,12)}-${h.slice(12,16)}-${h.slice(16,20)}-${h.slice(20)}`;
}

for (const [label, id] of [['randomUUID (v4)', randomUUID()], ['uuidv7()', uuidv7()]]) {
    console.log(`${label.padEnd(16)} ${id}  strict=${STRICT.test(id)}  rfc9562=${RFC9562.test(id)}`);
}
randomUUID (v4)  9512ba29-f62c-4ac4-ba0e-3663b27d9e77  strict=true   rfc9562=true
uuidv7()         01a0029b-770a-79cd-b80e-725e628cf965  strict=false  rfc9562=true

Same generator, same codebase, one passes and one does not. If you are weighing the move to v7, what the public benchmarks actually show is the honest version of the performance case — but budget an afternoon for finding every validator first.

The nibble almost nobody checks

The loose regex — 32 hex digits in a 8-4-4-4-12 shape — accepts two things that are not UUIDs at all.

Version 0. There is no version 0. The nibble is reserved and a UUID carrying it is malformed.

Wrong variant. The variant field lives in the top bits of the ninth byte, and RFC 9562's variant is 10xx in binary, which means that hex digit must be 8, 9, a or b. A c there is the reserved Microsoft variant — the old COM/OLE layout. It is a real thing, it is not an RFC 9562 UUID, and if it turns up in your system something upstream is producing legacy identifiers.

Both slip straight through a shape-only check. This is the part of the structure that the breakdown of every UUID version covers in detail — the version and variant bits are the only self-describing part of the format, and skipping them means you have validated the punctuation and nothing else.

Nil and max break the scheme on purpose

Two special values are defined outside the version/variant system:

  • Nil: 00000000-0000-0000-0000-000000000000
  • Max: ffffffff-ffff-ffff-ffff-ffffffffffff

Both are legitimate per RFC 9562, and both fail any version/variant check by construction — nil has version 0, max has version f. So a correct validator cannot be one regex. It has to be the pattern plus two explicit exceptions, which is exactly the sort of detail that never survives being copied out of an answer box.

Whether you should accept them is a separate question. Nil usually means "unset" leaked out of a struct somewhere, and accepting it into a foreign key column is rarely what you meant. Being deliberate about it is the point.

Your standard library is a parser, not a validator

Look at the last column of that table again. Python's uuid.UUID() accepts every single input, including the wrong variant, version 0, braces, the urn:uuid: prefix and the unhyphenated form.

That is not a bug. uuid.UUID() is built to ingest identifiers from wherever they came from — Windows registry dumps, database drivers, URNs — and normalise them. Leniency is the feature. Most language standard libraries and popular packages behave similarly, which is why they are the wrong tool for "reject anything that is not a well-formed RFC 9562 UUID from an untrusted client".

If you want a validator, decide explicitly what you accept:

import re
import uuid

RFC9562 = re.compile(
    r'^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', re.I
)
NIL = '00000000-0000-0000-0000-000000000000'
MAX = 'ffffffff-ffff-ffff-ffff-ffffffffffff'


def is_uuid(s, *, allow_special=False):
    if allow_special and s.lower() in (NIL, MAX):
        return True
    return bool(RFC9562.match(s))

Better still, skip the regex. Parse the thing, then interrogate the fields — uuid.UUID(s).version and .variant tell you what you actually have, and you get a real error object instead of a boolean. The regex is only worth it at the edge, where you want to reject malformed input before it reaches any parsing code at all.

Run it yourself

The full comparison, standalone:

import re
import uuid

A = re.compile(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')
B = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', re.I)
C = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', re.I)

NIL = '00000000-0000-0000-0000-000000000000'
MAX = 'ffffffff-ffff-ffff-ffff-ffffffffffff'

cases = [
    ('v4',                '9f5a4c1e-3b2d-4a7f-8c6e-1d2e3f4a5b6c'),
    ('v7 (RFC 9562)',     '0190f3c1-8b7a-7c3d-9e2f-1a2b3c4d5e6f'),
    ('v8 (RFC 9562)',     '0190f3c1-8b7a-8c3d-9e2f-1a2b3c4d5e6f'),
    ('nil UUID',          NIL),
    ('max UUID',          MAX),
    ('v4, wrong variant', '9f5a4c1e-3b2d-4a7f-cc6e-1d2e3f4a5b6c'),
    ('version 0',         '9f5a4c1e-3b2d-0a7f-8c6e-1d2e3f4a5b6c'),
    ('v4 uppercase',      '9F5A4C1E-3B2D-4A7F-8C6E-1D2E3F4A5B6C'),
    ('braces',            '{9f5a4c1e-3b2d-4a7f-8c6e-1d2e3f4a5b6c}'),
    ('no hyphens',        '9f5a4c1e3b2d4a7f8c6e1d2e3f4a5b6c'),
    ('urn:uuid: prefix',  'urn:uuid:9f5a4c1e-3b2d-4a7f-8c6e-1d2e3f4a5b6c'),
]


def rfc9562(s):
    return bool(C.match(s)) or s.lower() in (NIL, MAX)


def stdlib(s):
    try:
        uuid.UUID(s)
        return True
    except (ValueError, AttributeError):
        return False


def mark(ok):
    return 'accept' if ok else '  -   '


print(f"{'input':<20} {'loose':>8} {'[1-5]':>8} {'RFC 9562':>9} {'uuid.UUID()':>12}")
for label, value in cases:
    print(f'{label:<20} {mark(bool(A.match(value))):>8} {mark(bool(B.match(value))):>8} '
          f'{mark(rfc9562(value)):>9} {mark(stdlib(value)):>12}')

To see the structure rather than just a pass or fail, paste an identifier into the decoder on the UUID generator — it splits out the version, the variant and, for the time-based versions, the embedded timestamp, which is usually enough to work out which system produced an ID you did not expect.

What to change

Search your codebase for [1-5] today. Route constraints, request validators, database check constraints, log parsers, API gateway rules. It is a two-character fix — [1-8] — and it is much cheaper to make before you migrate than during.

Decide whether you check the version at all. Most code does not care whether an ID is v4 or v7, only that it is a well-formed identifier. If that is you, check the shape and the variant nibble and leave the version alone — then the next RFC does not break you.

Handle nil explicitly. Either accept it as a sentinel everywhere or reject it everywhere. Silently allowing it into foreign keys is how a zeroed struct ends up looking like a real row.

Do not use your standard library's constructor as a validator. It is built to be permissive. If you need strictness, write it down where a reader can see it.

And if you are still choosing a format rather than validating one, the UUID versus ULID comparison covers the trade-off that makes people move to v7 in the first place — which is what turns this regex from trivia into an incident.

More Articles

llms.txt After Eighteen Months: What the Request Logs Show

Adoption reached 10% of domains, but AI crawlers fetched llms.txt 408 times across 500M visits. The evidence, the audience that does read it, and what to ship.

25 August, 2026

Your Link Preview Is Broken Because the Crawler Never Saw the Page

Blank or wrong link previews are usually a crawler problem, not a tag problem: bot protection, client-side rendering, WebP images - and how to reproduce it.

23 August, 2026

Same Lightness, 12× the Brightness: HSL vs OKLCH

At HSL lightness 50% the measured luminance swings 12.9x across hue. At OKLCH lightness 0.62 it swings 1.2x. Here is the maths, and what it costs you.

20 August, 2026