Back to all posts
Guide
9 min read

UUIDv7 vs UUIDv4: 200,000 Rows Measured

DevToolLab Team

DevToolLab Team

September 14, 2026

UUIDv7 vs UUIDv4: 200,000 Rows Measured

A random primary key is a fine identifier and an awkward B-tree key. Every insert lands at an unpredictable point in the index, so the pages you need are rarely the pages you just touched, and the write pattern that results is the reason "stop using UUIDs as primary keys" became conventional wisdom.

UUIDv7 changes the key, not the schema. It puts a 48-bit millisecond timestamp in the leading bits so new values sort after old ones. We benchmarked it against UUIDv4 across 200,000 rows: inserts ran 1.6x to 2.3x faster and a time-range scan ran 11.7x to 15.8x faster. The widely repeated claim that the index gets about 25 percent smaller did not reproduce at all.

What UUIDv7 Actually Is

UUIDv7 is a 128-bit identifier defined by RFC 9562, published in May 2024, whose first 48 bits are a big-endian Unix timestamp in milliseconds, followed by the 4-bit version field, then random data with the 2-bit variant marker. It is the same size and the same textual format as UUIDv4. Only the bit layout changed.

The consequence is that lexicographic order matches creation order. Two UUIDv7 values generated a second apart sort correctly as strings, as bytes, and as a native uuid column, which is what makes the index behave.

The Benchmark

Same rows, same schema, same machine, one variable. This script needs Node 22 or newer for node:sqlite and no dependencies.

js
// uuid-bench.mjs - Node 22+, no dependencies. Run: node uuid-bench.mjs
import { DatabaseSync } from "node:sqlite"
import { randomUUID, randomFillSync } from "node:crypto"
import { statSync, rmSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"

const ROWS = 200_000
const HEX = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"))

// RFC 9562 UUIDv7: 48-bit big-endian Unix millisecond timestamp, version 7, variant 10.
const buf = new Uint8Array(16)
function uuidv7(ms) {
  randomFillSync(buf)
  buf[0] = (ms / 2 ** 40) & 0xff
  buf[1] = (ms / 2 ** 32) & 0xff
  buf[2] = (ms / 2 ** 24) & 0xff
  buf[3] = (ms / 2 ** 16) & 0xff
  buf[4] = (ms / 2 ** 8) & 0xff
  buf[5] = ms & 0xff
  buf[6] = (buf[6] & 0x0f) | 0x70 // version 7
  buf[8] = (buf[8] & 0x3f) | 0x80 // variant 10xx
  let s = ""
  for (let i = 0; i < 16; i++) {
    s += HEX[buf[i]]
    if (i === 3 || i === 5 || i === 7 || i === 9) s += "-"
  }
  return s
}

function bench(label, makeId) {
  const file = join(tmpdir(), `uuidbench-${label}-${process.pid}.db`)
  rmSync(file, { force: true })
  const db = new DatabaseSync(file)
  db.exec("PRAGMA journal_mode = WAL")
  db.exec("CREATE TABLE events (id TEXT PRIMARY KEY, payload TEXT NOT NULL)")
  const insert = db.prepare("INSERT INTO events (id, payload) VALUES (?, ?)")

  const base = Date.UTC(2026, 0, 1)
  const ids = Array.from({ length: ROWS }, (_, i) => makeId(base + i)) // pre-generate, so we time the insert
  const payload = "x".repeat(64)

  const t0 = performance.now()
  db.exec("BEGIN")
  for (const id of ids) insert.run(id, payload)
  db.exec("COMMIT")
  const ms = performance.now() - t0

  db.exec("PRAGMA wal_checkpoint(TRUNCATE)")
  const bytes = statSync(file).size

  // Rows written in the first 10% of the time range: a range scan for v7, a full scan for v4.
  const cutoff = ids.slice(0, ROWS / 10).reduce((a, b) => (a > b ? a : b))
  const t1 = performance.now()
  let found = 0
  for (let i = 0; i < 20; i++) found = db.prepare("SELECT COUNT(*) c FROM events WHERE id <= ?").get(cutoff).c
  const scanMs = (performance.now() - t1) / 20

  db.close()
  rmSync(file, { force: true })
  return { label, ms, bytes, scanMs, found }
}

console.log(`inserting ${ROWS.toLocaleString("en-US")} rows, TEXT PRIMARY KEY, Node ${process.version}\n`)
const results = [bench("uuidv4", () => randomUUID()), bench("uuidv7", (ms) => uuidv7(ms))]

console.log(`${"key".padEnd(8)} ${"insert".padStart(10)} ${"db size".padStart(11)} ${"range scan".padStart(12)}`)
console.log("-".repeat(46))
for (const r of results)
  console.log(
    `${r.label.padEnd(8)} ${(r.ms / 1000).toFixed(2).padStart(8)}s ${(r.bytes / 1024 / 1024).toFixed(1).padStart(9)} MB ${r.scanMs.toFixed(1).padStart(10)} ms`,
  )

const [v4, v7] = results
console.log(`\ninsert:     v7 is ${(v4.ms / v7.ms).toFixed(2)}x the speed of v4`)
console.log(`db size:    v7 is ${(((v4.bytes - v7.bytes) / v4.bytes) * 100).toFixed(1)}% smaller`)
console.log(`range scan: v7 is ${(v4.scanMs / v7.scanMs).toFixed(2)}x the speed of v4 (matched ${v7.found.toLocaleString("en-US")} rows)`)

One run of four, on September 14, 2026 with Node 25.5.0:

text
inserting 200,000 rows, TEXT PRIMARY KEY, Node v25.5.0

key          insert     db size   range scan
----------------------------------------------
uuidv4       0.85s      30.8 MB        9.9 ms
uuidv7       0.45s      31.0 MB        0.6 ms

insert:     v7 is 1.89x the speed of v4
db size:    v7 is -1.0% smaller
range scan: v7 is 15.75x the speed of v4 (matched 20,000 rows)

What Reproduced and What Did Not

Across four runs the insert speedup ranged from 1.6x to 2.3x and the range scan speedup from 11.7x to 15.8x. Both are large, both are stable, and both have the same cause: v7 keys arrive in order, so inserts append to the rightmost page instead of scattering, and a query for a time range becomes a contiguous index scan instead of a full table scan.

The size result was stable too, and it goes the other way. The v4 database measured 30.7 to 30.8 MB and the v7 database measured 31.0 MB in every run, so v7 came out about 1 percent larger, not 25 percent smaller.

That is worth stating plainly because the 25 percent figure is widely quoted. It comes from setups this benchmark does not reproduce: UUIDs stored as 16 raw bytes in Postgres or MySQL, where sequential insertion leaves B-tree pages densely packed and random insertion leaves them half full. Here the keys are 36-character TEXT in SQLite, which stores them as strings of identical length either way. Take the ordering wins as general and the storage win as dependent on your engine and column type. If index size is your reason for migrating, measure it on your own database before you commit.

Generating One in PostgreSQL 18

PostgreSQL 18 added UUIDv7 to core, so no extension is needed.

PostgreSQL 18 documentation, section 9.14 UUID Functions, showing gen_random_uuid() and uuidv4() generating version 4 UUIDs and uuidv7 with an optional shift interval parameter generating version 7 time-ordered UUIDs
PostgreSQL 18 documentation, section 9.14 UUID Functions, showing gen_random_uuid() and uuidv4() generating version 4 UUIDs and uuidv7 with an optional shift interval parameter generating version 7 time-ordered UUIDs

The documentation describes uuidv7() as generating "a version 7 (time-ordered) UUID. The timestamp is computed using UNIX timestamp with millisecond precision + sub-millisecond timestamp + random." It takes an optional shift interval that offsets the computed timestamp, useful for backfilling historical rows, and the valid range is the whole span of the 48-bit field: 1970-01-01 00:00:00 UTC to approximately the year 10889. PostgreSQL 18 also added uuidv4() as an explicit alias for gen_random_uuid().

On older PostgreSQL versions, or in any other engine, generate the value in the application. The only requirement is that your generator preserves ordering within a single millisecond, which the implementation above does not guarantee and production libraries do.

The Cost Nobody Mentions

UUIDv7 embeds the creation time in the identifier, in plaintext, recoverable by anyone who sees it. That is the entire point, and it is also a disclosure.

If your identifiers appear in URLs, in emails, or in an API response to a third party, a v7 primary key tells the recipient exactly when that row was created to the millisecond. Two identifiers reveal the interval between the events. For an invoice ID or a support ticket that is harmless. For a user account, a password reset token, or anything where signup timing is competitively interesting, it is information you were not previously publishing. UUIDv4 leaks nothing because it is random.

The practical split is to use v7 for internal primary keys where the ordering pays for itself, and v4 or an opaque public identifier for anything a customer can see.

How to Adopt It

  1. Use it for new tables first. The benefits are on insert and on range scan, both of which apply immediately to a new table and neither of which requires touching existing data.
  2. Do not rewrite existing primary keys to get the insert win. Rewriting a key column rewrites every index and every foreign key that references it. The write pattern improves for rows you have not inserted yet anyway.
  3. Check whether the ID is public before switching. Anything exposed in a URL or an API response now carries a timestamp.
  4. Measure size on your own engine. The storage argument is engine-specific, as the benchmark above shows. The ordering argument is not.
  5. Prefer the database function where you have one. uuidv7() in PostgreSQL 18 removes a class of bug where two application instances disagree about the clock.

Conclusion

UUIDv7 is a cheap change with two well-supported wins, faster ordered inserts and dramatically faster time-range queries, and one cost that rarely gets mentioned, which is that the identifier now tells everyone when the row was created. The storage claim that gets repeated alongside those wins did not hold in this benchmark and is worth verifying on your own engine before it appears in a migration proposal. Run the script above against your actual row count; it takes under a second.

  • UUID v7 Generator - produce time-ordered UUIDs and confirm that values generated in sequence really do sort in order before you rely on it.
  • UUID Generator - generate v4 values for the public-facing identifiers where you specifically do not want a readable creation time.
  • ULID Generator - the main alternative to v7, sortable and shorter in text form, worth comparing before you settle on a format.
  • NanoID Generator - when the identifier is going in a URL and length matters more than sortability or database ordering.

Related Posts

Best Database Migration Tools in 2026

Flyway, Liquibase, Atlas, Bytebase, Prisma Migrate and Alembic compared on license, price and drift detection, after Liquibase left Apache 2.0.

By DevToolLab Team

Cybersecurity Lab Gear for Students 2026

Kali runs in 2GB of RAM. Security Onion standalone wants 24GB and refuses to run on ARM. What a security student actually needs to buy, and what to skip.

By DevToolLab Team

How LLM Tokenization Actually Works

A model never sees letters. We built a real BPE tokenizer on OpenAI's published vocabularies and measured why strawberry, numbers and Hindi all go wrong.

By DevToolLab Team