DEV Community

Implementing UUID v7 by hand: time-sortable primary keys (and the same-millisecond trap)

Random UUID v4 makes a poor primary key: values land all over the index, so B-tree inserts scatter across pages and page splits go up. UUID v7 puts a millisecond timestamp in the leading bits (standardized in RFC 9562), so generation order equals sort order and you get locality back. I needed a UUID generator that runs entirely in the browser, and while building it I implemented v7 by hand instead of pulling in a library. Here is the layout, the implementation, the trap I hit, and when each version is actually the right choice.

The v7 layout

The 128 bits break down like this:

| 48bit unix_ms | 4bit ver(=7) | 12bit rand_a | 2bit variant | 62bit rand_b |

The first 48 bits are the Unix timestamp in milliseconds, big-endian. Then the version (7) and variant bits, and everything else is random. The random part must come from a cryptographic source (crypto.getRandomValues) - not Math.random().

Implementation: putting the bits in the right place

The whole job is "pack the timestamp into the first six bytes, most significant byte first, then overwrite the version and variant nibbles."

function rnd(n){
  const a = new Uint8Array(n);
  crypto.getRandomValues(a);
  return a;
}

function hex(b){
  let s='';
  for(const x of b) s += ('0'+x.toString(16)).slice(-2);
  return s;
}

function uuidV7() {
  const ts = Date.now(); // 48-bit millisecond timestamp
  const b = rnd(16);
  // 48-bit big-endian millisecond timestamp (top six bytes)
  b[0] = (ts / 0x10000000000) & 0xff; // ts >> 40
  b[1] = (ts / 0x100000000) & 0xff;   // ts >> 32
  b[2] = (ts / 0x1000000) & 0xff;     // ts >> 24
  b[3] = (ts / 0x10000) & 0xff;       // ts >> 16
  b[4] = (ts / 0x100) & 0xff;         // ts >> 8
  b[5] = ts & 0xff;
  b[6] = (b[6] & 0x0f) | 0x70;        // version 7
  b[8] = (b[8] & 0x3f) | 0x80;        // variant (10xx)
  const h = hex(b);
  return `${h.slice(0,8)}-${h.slice(8,12)}-${h.slice(12,16)}-${h.slice(16,20)}-${h.slice(20)}`;
}

The detail worth remembering: shift the digits down with division, not with >>. ts >> 40 does not work, because JavaScript's bitwise operators coerce their operands to 32 bits and a 48-bit value gets mangled. Division followed by & 0xff is safe.

For v4, by the way, don't hand-roll anything if the platform gives you the standard API - it is both faster and safer.

function uuidV4() {
  if (crypto.randomUUID) return crypto.randomUUID(); // one call where it's supported
  const b = rnd(16);
  b[6] = (b[6]&0x0f)|0x40;
  b[8] = (b[8]&0x3f)|0x80;
  const h = hex(b);
  return `${h.slice(0,8)}-${h.slice(8,12)}-${h.slice(12,16)}-${h.slice(16,20)}-${h.slice(20)}`;
}

The trap: ordering is not guaranteed within a millisecond

If two IDs are generated in the same millisecond, their leading 48 bits are identical and everything after that is random - so their relative order is arbitrary. If you assume "generation order == strictly ascending", a burst of IDs will quietly break that assumption.

When you do need strict monotonicity, use the 12-bit rand_a field as a counter: increment it within the same millisecond, and reseed it when the millisecond changes.

let lastMs = 0, seq = 0;
function uuidV7Monotonic() {
  const ts = Date.now();
  if (ts === lastMs) seq = (seq + 1) & 0x0fff;        // same ms: +1 (12 bits)
  else { lastMs = ts; seq = rnd(2)[0] & 0x0fff; }      // new ms: start from random
  // ...store seq in the low 12 bits of b[6..7], overwriting version = 7...
}

If "roughly time-ordered" is good enough for your use case, plain random is fine. The design decision to make up front is simply whether you need strict monotonicity - not which implementation looks cleverer.

Checking it instead of trusting the spec

I measured the following in both Node and the browser rather than assuming the spec held:

  • Are the version and variant bits actually set? (u[14] === '7', and u[19] is one of 8, 9, a, b.)
  • Do two IDs generated across a millisecond boundary always sort ascending?
  • Generate 1000 IDs inside a single millisecond - does the plain implementation break ascending order, and does the counter version hold?

Reproducing the failure yourself, on purpose, is what makes the edge of the trap visible. "The spec says it should" is not the same as having seen it.

v4 vs v7 vs ULID

Time-sortable Locality Standard In one line
UUID v4 No Low RFC 9562 Fully random. Spreads well, hard on indexes
UUID v7 Yes (ms granularity) High RFC 9562 Good primary key. Drops into an existing UUID column
ULID Yes High De facto 26-char Base32. Worth a look if you don't need UUID compatibility

If you are using UUIDs as primary keys, v7 is worth considering first. In practice the big win is that it fits your existing uuid / UUID column type unchanged - no migration of the column, just of the generator.

The result

I published the generator as a browser tool: it does v4 and v7, with options for count, hyphens, and uppercase. Generation happens in the page and nothing is sent to a server: https://hashitosystem.com/tools/uuidgen/

Wrap-up

v7 is the right fit for "I want roughly time-ordered keys with good locality." The implementation is just splitting the timestamp into bytes with division and overwriting the version/variant nibbles. The one caveat to internalize: ordering within a single millisecond is not guaranteed, so pair it with a counter if you need strict monotonicity. Get that one point right and it is safe to use.

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

Comments

No comments yet. Start the discussion.