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?
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 of10^-19per 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 of10^-9per bucket, aggregating to roughly10^-4per 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^-6at 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
idempotency_key_generation_duration_ms— Histogram per key type (uuidv4|uuidv7|ulid). Alert if p99 exceeds1 ms; a spike often means CSPRNG contention.pg_index_leaf_fragmentation_ratio— Gauge from a scheduledpgstatindex()query against the dedup table’s primary key. Alert if it exceeds30%for a UUIDv7/ULID-backed table — it means time-ordering assumptions have broken (check for clock rollback or a misconfigured generator).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 for60 seconds.idempotency_key_typestructured 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.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.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.
Related
- Idempotency Key Generation Strategies — parent page covering the full key lifecycle: generation, atomic registration, response caching, and TTL expiry.
- How to Generate Cryptographically Secure Idempotency Keys — CSPRNG setup, entropy pool configuration, and collision-resistance test harnesses that any of the three formats in this guide depend on.
- Using Redis SETNX for Distributed Request Deduplication — atomic registration patterns that work identically regardless of which key format you generate.
- PostgreSQL Unique Constraints vs. Application-Level Checks — the hard collision backstop referenced throughout this page’s failure table.
- Choosing TTL Values for Idempotency Keys — how key format interacts with expiry windows and archival retention.
- Idempotency Fundamentals & API Guarantees — parent section covering the end-to-end exactly-once contract and failure boundary map.