UUIDv4 vs UUIDv7 vs ULID for Idempotency Keys

Part of: Idempotency Key Generation Strategies

Picking a key format is not cosmetic. It determines whether your deduplication table’s primary index stays compact and cache-resident or bloats under write amplification, whether the key discloses timing information to clients, and whether two nodes can generate colliding values under real production concurrency. This page is a direct decision guide for idempotency key generation inside the broader idempotency contract: given a specific storage engine and exposure requirement, which of UUIDv4, UUIDv7, or ULID should you generate, and how do you prove the choice is correct before it ships. If you have not yet reviewed how to generate cryptographically secure idempotency keys, do that first — this page assumes a CSPRNG-backed generator is already in place and focuses purely on which layout of bits to produce.


Problem statement and prerequisites

What you are deciding: the byte layout of the value stored in your Idempotency-Key field and its primary-key column — random (UUIDv4), time-ordered binary (UUIDv7), or time-ordered base32 text (ULID).

Prerequisites:

  • You understand the atomic check-and-set registration protocol that every key format must support, regardless of layout.
  • Your deduplication store is either a B-tree-indexed relational database (PostgreSQL, MySQL) or an LSM-tree store (DynamoDB, Cassandra) — the recommendation differs sharply between the two.
  • You can modify the primary key column type and are willing to benchmark before committing to a layout in a table that already holds production traffic.

Entropy and layout at a glance

  • UUIDv4 — 122 bits of cryptographic randomness, 6 fixed version/variant bits, zero temporal ordering. 16 bytes binary, 36 characters as hyphenated text.
  • UUIDv7 — 74 bits of randomness plus a 48-bit millisecond Unix timestamp prefix. 16 bytes binary, 36 characters as hyphenated text.
  • ULID — 80 bits of randomness plus a 48-bit millisecond timestamp prefix, encoded in Crockford base32. 16 bytes binary, 26 characters as text — no hyphens, case-insensitive, URL-safe without escaping.

The timestamp prefix is what makes UUIDv7 and ULID k-sortable: values generated in the same millisecond sort adjacently, and values from later milliseconds always sort after earlier ones at the database level. UUIDv4 has no such property — every value is uniformly distributed across the entire 128-bit space.


Decision diagram

Use this to pick a format from two questions: does the write path hit a B-tree index, and does the key need to be a short, external-facing string?

Idempotency key format decision tree Decision tree starting with the question "B-tree index? (PostgreSQL/MySQL)". A "No" branch leads to UUIDv4 for LSM-tree stores like DynamoDB or Cassandra. A "Yes" branch leads to a second question, "Exposed externally or need lexical sort?" — "Yes" leads to ULID, "No" leads to UUIDv7. B-tree index? (PostgreSQL / MySQL) No Yes UUIDv4 122 bits random LSM-tree partition spread DynamoDB, Cassandra No timestamp ordering No timestamp disclosure Exposed externally or need lexical sort? Yes No ULID 80-bit random + 48-bit ts 26-char base32 string URL-safe, no hyphens Monotonic per-ms counter Good B-tree locality UUIDv7 74-bit random + ts Native UUID (16B) RFC 9562 standard Excellent locality Timestamp visible

Comparison table

Property UUIDv4 UUIDv7 ULID
Total entropy 122 bits random 74 bits random + 48-bit ms timestamp 80 bits random + 48-bit ms timestamp
Time-ordered No Yes (millisecond) Yes (millisecond)
B-tree index locality Poor — random insert causes page splits Excellent — sequential insert prefix Excellent — sequential insert prefix
Binary storage 16 bytes 16 bytes 16 bytes
Text encoding 36 chars, hyphenated 36 chars, hyphenated 26 chars, Crockford base32, no hyphens
Monotonic within same ms No No by spec (library-dependent) Yes, with a monotonic entropy source
Timestamp disclosed No Yes, millisecond creation time Yes, millisecond creation time
Library support Stdlib in Python, Go, Java, Node.js google/uuid v1.6+ (Go), Python 3.12+ stdlib, com.github.f4b6a3 (Java) oklog/ulid/v2 (Go), python-ulid, ulid-creator (Java)
Best fit DynamoDB, Cassandra, max-entropy needs PostgreSQL/MySQL primary keys, native UUID column External IDs, log correlation, PostgreSQL/MySQL

Collision math

At 1 billion keys generated per day:

  • UUIDv4 (122 random bits): birthday-bound collision probability is approximately n² / 2^123 — on the order of 10^-19 per day. Negligible for any realistic system lifetime.
  • UUIDv7 (74 random bits): collisions depend on how many keys share the same millisecond bucket. At 1 billion keys/day evenly spread, each millisecond bucket holds roughly 11,600 keys on average; within a single bucket the collision probability is 11600² / 2^75, on the order of 10^-9 per bucket, aggregating to roughly 10^-4 per day at sustained peak concurrency. This is why high-throughput generators must not skip the random suffix even though the timestamp already provides uniqueness pressure.
  • ULID (80 random bits): the same bucket-level birthday math with 6 more random bits reduces the aggregate daily collision probability by roughly two orders of magnitude versus UUIDv7, landing near 10^-6 at the same throughput — acceptable for most fintech workloads but worth pairing with a database unique constraint as a hard backstop regardless.

None of these numbers substitute for enforcement: always add a unique constraint or conditional write at the storage layer so a collision — however unlikely — produces a rejected write, not silent data corruption.


Step-by-step implementation

Step 1 — Generate all three key types in Python

import uuid
from ulid import ULID  # pip install python-ulid

def new_uuidv4() -> str:
    return str(uuid.uuid4())

def new_uuidv7() -> str:
    # Python 3.12+ stdlib; use the `uuid6` package on older runtimes
    return str(uuid.uuid7())

def new_ulid() -> str:
    return str(ULID())  # 26-char Crockford base32, e.g. "01HXYZ3ABCDEFGHJKMNPQRSTVW"

Step 2 — Generate all three key types in Go

package keys

import (
    "github.com/google/uuid"
    "github.com/oklog/ulid/v2"
    "math/rand"
    "time"
)

func NewUUIDv4() string {
    return uuid.NewString()
}

func NewUUIDv7() string {
    id, err := uuid.NewV7()
    if err != nil {
        panic(err) // entropy failure — hard stop, do not silently degrade
    }
    return id.String()
}

func NewULID() string {
    entropy := ulid.Monotonic(rand.New(rand.NewSource(time.Now().UnixNano())), 0)
    return ulid.MustNew(ulid.Timestamp(time.Now()), entropy).String()
}

Step 3 — Store keys as 16-byte binary values in PostgreSQL

Never store any of these three formats as text or varchar in a hot dedup table — binary storage halves the row size and lets the index compare fixed-width values directly.

-- UUIDv4 and UUIDv7 use PostgreSQL's native 16-byte uuid type
CREATE TABLE dedup_uuidv4 (
    id          uuid PRIMARY KEY,
    created_at  timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE dedup_uuidv7 (
    id          uuid PRIMARY KEY,
    created_at  timestamptz NOT NULL DEFAULT now()
);

-- ULID's base32 text form must be decoded to raw bytes before storage;
-- store as bytea(16) to avoid paying for the 26-char string on disk and in the index
CREATE TABLE dedup_ulid (
    id          bytea PRIMARY KEY,
    created_at  timestamptz NOT NULL DEFAULT now()
);
# Decode a ULID string to its 16 raw bytes before inserting
from ulid import ULID
import psycopg

def ulid_to_bytes(ulid_str: str) -> bytes:
    return ULID.from_str(ulid_str).bytes  # 16 bytes

with psycopg.connect("dbname=dedup") as conn:
    with conn.cursor() as cur:
        cur.execute(
            "INSERT INTO dedup_ulid (id) VALUES (%s)",
            (ulid_to_bytes(str(ULID())),),
        )

Step 4 — Benchmark insert locality and index bloat per key type

Load 2,000,000 rows into each table, then compare index size and page-split behavior. A larger index for the same row count is direct evidence of write amplification from random insert order.

# Generate and time 2M inserts per table (run once per key type, adjust the generator call)
python3 - <<'PY'
import time, psycopg, uuid
from ulid import ULID

conn = psycopg.connect("dbname=dedup")
cur = conn.cursor()
start = time.time()
with cur.copy("COPY dedup_uuidv4 (id) FROM STDIN") as copy:
    for _ in range(2_000_000):
        copy.write_row([str(uuid.uuid4())])
conn.commit()
print("uuidv4 load seconds:", time.time() - start)
PY
-- Compare on-disk index size across the three tables after loading
SELECT relname AS table_name,
       pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM   pg_index i
JOIN   pg_class c ON c.oid = i.indexrelid
JOIN   pg_class t ON t.oid = i.indrelid
JOIN   (VALUES ('dedup_uuidv4'), ('dedup_uuidv7'), ('dedup_ulid')) AS names(relname) ON t.relname = names.relname
JOIN   pg_class relname_join ON true
WHERE  t.relname IN ('dedup_uuidv4', 'dedup_uuidv7', 'dedup_ulid');

-- Simpler equivalent per table:
SELECT pg_size_pretty(pg_relation_size('dedup_uuidv4_pkey'))  AS uuidv4_index_size;
SELECT pg_size_pretty(pg_relation_size('dedup_uuidv7_pkey'))  AS uuidv7_index_size;
SELECT pg_size_pretty(pg_relation_size('dedup_ulid_pkey'))    AS ulid_index_size;

Expect the UUIDv4 index to run roughly 20-35% larger than the UUIDv7 or ULID index at equal row counts, because random insert order leaves half-empty pages behind after splits, while the sequential prefix on the other two packs pages near capacity.

Step 5 — Verify monotonicity and timestamp exposure

import uuid
from ulid import ULID

# UUIDv7: confirm the first 48 bits decode to a sane, increasing timestamp
def uuidv7_timestamp_ms(u: uuid.UUID) -> int:
    return int.from_bytes(u.bytes[0:6], "big")

a, b = uuid.uuid7(), uuid.uuid7()
assert uuidv7_timestamp_ms(a) <= uuidv7_timestamp_ms(b)

# ULID: confirm string comparison equals timestamp comparison
x, y = ULID(), ULID()
assert str(x) <= str(y)

Verification and testing

Confirm binary storage savings:

psql -d dedup -c "\d+ dedup_uuidv4"   # id column should report type uuid, 16 bytes fixed
psql -d dedup -c "\d+ dedup_ulid"     # id column should report type bytea

Confirm index page-split disparity with pgstattuple:

CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT * FROM pgstatindex('dedup_uuidv4_pkey');  -- inspect leaf_fragmentation
SELECT * FROM pgstatindex('dedup_uuidv7_pkey');  -- expect lower leaf_fragmentation

A leaf_fragmentation value materially higher on dedup_uuidv4_pkey than on dedup_uuidv7_pkey at equal row counts confirms the locality difference predicted above.

Confirm ULID decodes back to the same timestamp across languages:

python3 -c "from ulid import ULID; u = ULID(); print(u.timestamp)"
# Compare against the Go side:
go run - <<'EOF'
package main
import ("fmt"; "github.com/oklog/ulid/v2"; "time")
func main() {
    id := ulid.MustNew(ulid.Timestamp(time.Now()), nil)
    fmt.Println(ulid.Time(id.Time()))
}
EOF

Failure scenarios and debugging

Failure Scenario Remediation Steps Observability Hooks
Timestamp leakage in UUIDv7 exposes request timing to clients who receive the key in a response header Treat UUIDv7 keys as internal-only where timing disclosure matters; strip or re-encode before returning to untrusted clients. Use HMAC-derived deterministic keys for externally visible identifiers instead. idempotency_key_type structured log field; audit rule flagging uuidv7 values in outbound response bodies
Non-monotonic ULID generated within the same millisecond because two processes each seeded an independent, non-monotonic entropy source Use a single per-process monotonic entropy generator (ulid.Monotonic in Go, a process-local counter in Python) rather than a fresh CSPRNG call per key; do not share monotonic state across processes — sort guarantees only hold per-generator. ulid_monotonic_violation_total counter from a periodic sort-order audit job; alert on any non-zero value
Clock rollback (NTP correction) causes UUIDv7/ULID timestamps to move backward, breaking sort order and shrinking the effective random space for that millisecond Run chronyd in slew mode, not step mode, on all key-generating hosts. Reject or flag any generated key whose timestamp is more than 1 second behind the last observed timestamp on that host. system_clock_step_total (Gauge, alert if > 0); idempotency_key_timestamp_regression_total counter
UUIDv4 mistakenly used as the primary key on a high-write PostgreSQL table, causing index bloat and rising p99 write latency over weeks Migrate to UUIDv7 via a backfill: add a new uuid column, populate with uuid_generate_v7()-equivalent logic, swap the primary key during a maintenance window, then REINDEX and VACUUM FULL. pg_relation_size trend on the primary key index (Gauge); alert if index/row-count ratio grows > 15% week over week
ULID byte layout mismatch between two language runtimes’ libraries produces different binary encodings for what should be the same value, breaking cross-service key comparison Standardize on one reference implementation’s byte layout (Crockford base32, big-endian 48-bit timestamp then 80-bit randomness) and add a cross-language round-trip test in CI that encodes in one runtime and decodes in the other. ulid_cross_service_mismatch_total counter from integration tests; CI gate blocking merge on failure

SRE / observability checklist

  1. idempotency_key_generation_duration_ms — Histogram per key type (uuidv4 | uuidv7 | ulid). Alert if p99 exceeds 1 ms; a spike often means CSPRNG contention.
  2. pg_index_leaf_fragmentation_ratio — Gauge from a scheduled pgstatindex() query against the dedup table’s primary key. Alert if it exceeds 30% for a UUIDv7/ULID-backed table — it means time-ordering assumptions have broken (check for clock rollback or a misconfigured generator).
  3. idempotency_key_timestamp_regression_total — Counter incremented whenever a newly generated UUIDv7 or ULID timestamp is lower than the last one observed on the same host. Alert on any non-zero value; page if sustained for 60 seconds.
  4. idempotency_key_type structured log field — Tag every generated key with its format (uuidv4, uuidv7, ulid) so an audit query can detect accidental format drift after a library upgrade or a copy-pasted code path from another service.
  5. idempotency_key_collision_total — Counter of database unique constraint violations on the dedup table. Alert on any non-zero value regardless of key type — this is the hard backstop behind the collision math above.
  6. ulid_monotonic_violation_total — Counter from a periodic job that re-sorts a sample of recently inserted ULIDs by string value and by insertion order and diffs the two. Alert on any non-zero value.