DEV Community

takahiro hashito
takahiro hashito

Posted on

Decoding a UUID by hand: version, variant, and the embedded timestamp

Background

A UUID (Universally Unique Identifier) is a 128-bit value written as 32 hex digits, used as an identifier that will not collide across systems. I run a small UUID generator on a personal tools site. Recently I added the other half: paste a UUID in, and it tells you which version it is, which variant it belongs to, and — when the version carries one — the timestamp baked into it.

The reason was practical. IDs arriving from other systems come with no documentation. When you generate them yourself you know the scheme; when you receive them you are guessing. The spec makes this mechanically decidable, so I wrote the reader.

What this post is actually about is not the feature. It is how I convinced myself the decoder was correct, which turned out to be the interesting part.

How it works

A UUID is 32 hex digits. The canonical form groups them 8-4-4-4-12 with hyphens, but for reading you want the hyphens gone and a flat 32-character string.

Only two positions matter for classification (0-based indices into that flat string):

What Index Meaning
version 12 the UUID scheme (1/3/4/5/6/7/8)
variant 16 which family of specs this UUID belongs to

"Variant" is the less familiar one. UUIDs have several historical lineages, and the variant bits say which. Everything in normal use today is the RFC 4122 variant (carried forward by RFC 9562), meaning the top two bits of nibble 16 are 10.

Three versions embed a timestamp: v1, v6 and v7. This is where it gets awkward, because the epoch and the unit differ by version:

  • v7: the first 48 bits are milliseconds since the Unix epoch. Read them as a number, done.
  • v1 / v6: 100-nanosecond intervals since 1582-10-15, and in v1 the 60-bit timestamp is split across three fields stored out of order.

v4 is random and v3/v5 are name hashes, so there is no time to recover.

Implementation

Dependency-free JavaScript. Normalize the input, read the variant, then branch per version to recover the time. Hyphenated, unhyphenated, uppercase and brace-wrapped GUID strings all show up in practice, so everything that is not a hex digit is stripped before the length check.

const GREG_OFFSET_MS = 12219292800000; // ms between 1582-10-15 and 1970-01-01

function normalize(raw) {
  return String(raw).replace(/[^0-9a-fA-F]/g, "").toLowerCase();
}

function variantOf(nibble) {
  if ((nibble & 0x8) === 0) return "NCS (legacy)";
  if ((nibble & 0xc) === 0x8) return "RFC 4122 / RFC 9562";
  if ((nibble & 0xe) === 0xc) return "Microsoft GUID (legacy)";
  return "reserved for the future";
}

function inspect(raw) {
  const hex = normalize(raw);
  if (hex.length !== 32) return { input: raw, error: "not 32 hex digits" };
  const ver = parseInt(hex.charAt(12), 16);
  const variant = variantOf(parseInt(hex.charAt(16), 16));
  let unixMs = null;
  if (ver === 7) {
    unixMs = parseInt(hex.slice(0, 12), 16);
  } else if (ver === 1 || ver === 6) {
    const hi = hex.slice(0, 8), mid = hex.slice(8, 12), high = hex.slice(13, 16);
    const tick = ver === 1 ? BigInt("0x" + high + mid + hi)
                           : BigInt("0x" + hi + mid + high);
    unixMs = Number(tick / 10000n) - GREG_OFFSET_MS;
  }
  return { input: raw, version: ver, variant, unixMs,
           utc: unixMs == null ? null : new Date(unixMs).toISOString() };
}
Enter fullscreen mode Exit fullscreen mode

Three things carry the weight here.

  1. Test the variant masks from narrow to wide. & 0x8, then & 0xc, then & 0xe — the masks widen from one to three bits, so this order is the only correct one. Check Microsoft first and you will misclassify ordinary RFC UUIDs.
  2. v7 needs no BigInt. 48 bits is about 2.8e14, comfortably inside Number.MAX_SAFE_INTEGER (2^53), so parseInt is enough.
  3. v1 needs BigInt and a re-ordering. v1 splits its 60-bit timestamp into time_low (first 8 digits), time_mid (next 4) and time_hi (3 digits right after the version nibble). To rebuild it you concatenate high-to-low: time_hi + time_mid + time_low. v6 is v1 with those fields already in ascending order, so the concatenation reverses.

The final Number(tick / 10000n) - GREG_OFFSET_MS converts 100-ns Gregorian ticks to Unix milliseconds: divide by 10000 for ticks-to-ms, then subtract the 12219292800000 ms between 1582 and 1970.

Gotchas

How do you know code written from a spec is right? That is the real problem. Slide a field boundary by one digit and you do not get an obviously broken value — you get a plausible different date. And writing your own unit tests does not help, because you also author the expected values. If your reading of the spec is wrong, your test is wrong in exactly the same way.

What worked was agreement between two independent paths. RFC 9562 publishes one v1 example and one v7 example, and they are constructed to denote the same instant. Two completely different encodings, decoded by two completely different branches of my code — if they land on the same moment, both branches are validated at once.

Here is the run:

{"input":"017f22e2-79b0-7cc3-98c4-dc0c0c07398f","version":7,"variant":"RFC 4122 / RFC 9562","unixMs":1645557742000,"utc":"2022-02-22T19:22:22.000Z"}
{"input":"C232AB00-9414-11EC-B3C8-9F6BDECED846","version":1,"variant":"RFC 4122 / RFC 9562","unixMs":1645557742000,"utc":"2022-02-22T19:22:22.000Z"}
{"input":"9f1a7c54-8f3e-4b2a-b9d1-6e2c5a0f77aa","version":4,"variant":"RFC 4122 / RFC 9562","unixMs":null,"utc":null}
{"input":"{0199A9C0000070008000000000000000}","version":7,"variant":"RFC 4122 / RFC 9562","unixMs":1759489556480,"utc":"2025-10-03T11:05:56.480Z"}
{"input":"00000000-0000-0000-0000-000000000000","version":0,"variant":"NCS (legacy)","unixMs":null,"utc":null}
Enter fullscreen mode Exit fullscreen mode

Line 1 is the v7 example, line 2 the v1 example. Both came out as 2022-02-22T19:22:22.000Z. The parseInt path and the BigInt re-ordering path arrived independently at the same answer. Get either field layout wrong and that agreement disappears.

Two more findings from the same run:

  • Line 4: {0199A9C0000070008000000000000000} — brace-wrapped, unhyphenated, uppercase — decodes identically, because normalization runs first. Without it, every GUID pasted out of a Windows tool would be rejected.
  • Line 5: the nil UUID (all zeros) reports version: 0 and variant: "NCS (legacy)". That is not a bug; it is what those bits literally say. But surfacing it as "a legacy NCS UUID" is misleading, because nil means "no value". Bit extraction and human-facing labels belong in separate layers — I had collapsed them. The shipped tool now special-cases nil and max (all f) before classification.

The result

A site that runs on this: https://hashitosystem.com/tools/uuid/

Generation (v1 / v4 / v7, up to 1000 at a time) and inspection live on the same page. Classification and time math happen entirely in the browser; pasted UUIDs are never sent to a server.

Wrap-up

Classifying a UUID comes down to reading two of its 32 digits. Order the variant masks narrow-to-wide, re-order v1's split timestamp before handing it to BigInt, and the implementation stays short.

The verification is the part worth keeping. Code written straight from a spec cannot be validated by tests you wrote from the same reading. Look for a case where the spec encodes one fact two different ways, and check that your two code paths agree. When two unrelated routes reach the same answer, your own misreading cannot explain it.


This article is about my own side project. It was written with AI assistance.

Top comments (0)