Files
member-console/design/documents/reference-postgresql-pk-strategies.md
T
cgalo5758 bfe9cee0fe Consolidate design docs into documents directory
- Remove per-module projection files (README, architecture, companion,
  interfaces, model) under design/<module>/
- Add design/documents/ with numbered design docs, references, policies,
  and manifest
- Update design/README.md to describe the directory as a mirror of
  membcons-db's normative surfaces
- Record Decisions 140-141 in companion and glossary; update
  data-model.md schema organization
2026-08-21 00:55:42 -05:00

24 KiB
Raw Blame History

PostgreSQL primary key strategies: the definitive guide for 2026

UUIDv7 has emerged as the clear winner for new PostgreSQL applications, nearly closing the decade-long debate between sequential integers and UUIDs. For a system with 31 tables currently on BIGSERIAL, the optimal path depends on specific needs: retain BIGSERIAL if the database is single-node with no federation requirements, migrate to UUIDv7 if distributed generation or cross-system merging matters, or adopt the hybrid pattern (BIGSERIAL internally, UUID externally) for maximum flexibility. PostgreSQL 18's native uuidv7() function, released September 2025, marks the inflection point — benchmarks show UUIDv7 insert throughput matches BIGINT while delivering globally unique, time-ordered identifiers without any coordination. The performance catastrophe of random UUIDv4 keys — 20× more WAL, 31,000% more buffer hits, severe index bloat — is entirely solved by UUIDv7's time-ordered bit layout.


The UUID version landscape after RFC 9562

RFC 9562, published May 2024, superseded RFC 4122 and formalized UUID versions 6, 7, and 8 alongside the existing v1v5. Only three versions matter for primary key decisions today.

UUIDv4 remains the most widely deployed version. It fills 122 bits with pure randomness, making collision probability negligible (50% chance after ~2.71 quintillion IDs). Its strength is simplicity and privacy — no timestamps, no MAC addresses, no information leakage. Its fatal weakness as a database key is that random values scatter inserts across the entire B-tree, causing mid-page splits, ~67% leaf page density, and catastrophic WAL amplification at scale.

UUIDv7 is the version that changes everything. Its bit layout places a 48-bit Unix millisecond timestamp in the most significant bits, followed by 4 version bits, 12 bits of sub-millisecond precision or randomness, 2 variant bits, and 62 bits of randomness. This means opaque byte-by-byte comparison produces chronological ordering — no parsing required. The 48-bit timestamp covers dates through approximately 10,889 AD. Within a single millisecond, the 74 available random/counter bits provide 2^74 (~1.9 × 10²²) possible values, making same-millisecond collisions essentially impossible. RFC 9562 explicitly states: "Implementations SHOULD utilize UUIDv7 instead of UUIDv1 and UUIDv6 if possible."

UUIDv6 exists as a transitional format — a reordered UUIDv1 with properly sorted timestamp bits. It retains the Gregorian epoch (October 15, 1582) and optional MAC address. New systems should skip v6 entirely and use v7. Versions 13 and 5 serve niche purposes (MAC-based timestamps, deterministic name hashing) but are unsuitable as general-purpose primary keys. UUIDv8 provides a "bring your own layout" escape hatch with 122 custom bits — useful for vendor-specific schemes while maintaining UUID format compatibility.

Version Sortable Random bits Privacy PK suitability
v1 Scrambled timestamp 0 ⚠️ MAC leaked Poor
v4 Fully random 122 Best ⚠️ Degrades at scale
v6 Reordered v1 0 ⚠️ MAC default Good (prefer v7)
v7 Time-ordered 74 Moderate (time leaked) Best
v8 Custom Up to 122 Custom Depends on design

Alternative identifiers beyond UUIDs

The identifier landscape extends well beyond UUIDs. Each format makes different trade-offs between size, sortability, coordination requirements, and ecosystem compatibility.

TSID and Snowflake IDs fit in a BIGINT (8 bytes) — the most storage-efficient option. Twitter's Snowflake uses 41 timestamp bits + 10 machine ID bits + 12 sequence bits, yielding 4,096 IDs per millisecond per node. TSID (popularized by Vlad Mihalcea) offers a similar structure with configurable bit allocation and Crockford Base32 encoding (13 characters). Both achieve sequential insert performance matching BIGSERIAL while supporting distributed generation. The catch: they require machine ID coordination, making them unsuitable for serverless or ephemeral environments. Sonyflake, Sony's variant, trades throughput (256 IDs per 10ms) for supporting 65,536 machines.

ULID is UUIDv7's closest competitor: 128 bits with a 48-bit millisecond timestamp and 80 bits of randomness, encoded as a compact 26-character Crockford Base32 string. It predates UUIDv7 and solves the same problem. The critical difference: ULID lacks a formal RFC, uses a non-standard encoding, and doesn't map to PostgreSQL's native uuid type without conversion. Since UUIDv7 provides equivalent properties within the standard UUID format, ULID's advantage is primarily its shorter string representation.

KSUID (from Segment/Twilio) uses 160 bits (20 bytes) — a 32-bit second-precision timestamp plus 128 bits of randomness. The extra entropy provides the highest collision resistance of any format, but the 4 additional bytes over UUID increase index size. CUID2 takes a security-first approach, feeding timestamp, randomness, and host fingerprint through SHA-3 to produce an opaque 24-character string — deliberately destroying time-ordering to prevent information leakage. Nano ID generates pure random strings of configurable length (default 21 characters, ~126 bits entropy) with a customizable alphabet.

For PostgreSQL specifically, the storage type determines performance more than the ID format itself:

Format Bytes PG type String length Time-sorted Coordination needed
BIGSERIAL 8 BIGINT 19 Sequential Single database
Snowflake/TSID 8 BIGINT 1319 Yes Machine ID
xid 12 BYTEA 20 Yes Auto-detected
UUIDv7 16 UUID 36 Yes None
ULID 16 UUID/BYTEA 26 Yes None
KSUID 20 BYTEA/TEXT 27 Yes None
CUID2 varies TEXT 24 No None

Formats that map to native PostgreSQL types (BIGINT, UUID) outperform those requiring BYTEA or TEXT due to optimized comparison operators and pass-by-value semantics.


PostgreSQL internals: how key type choice cascades through the storage engine

Understanding PostgreSQL's storage architecture reveals why primary key type has such outsized performance impact. The effects compound across B-tree indexes, WAL generation, buffer cache utilization, and vacuum behavior.

Storage size matters more than you'd think. BIGINT occupies 8 bytes and is passed by value in a single Datum on 64-bit systems. UUID occupies 16 bytes and must be passed by reference, requiring pointer dereference and separate memory allocation for every comparison. In a B-tree leaf page (8,192 bytes minus headers), BIGINT keys yield roughly 450+ entries per page versus 300+ for UUID. This 33% density difference compounds: UUID primary key indexes are approximately 40% larger than BIGINT indexes for the same row count. Across every secondary index containing foreign keys and every join operation, that 8-byte difference accumulates.

B-tree page splits are where random keys cause catastrophic damage. When PostgreSQL inserts into a full B-tree leaf page, it splits the page. With sequential keys (BIGSERIAL, UUIDv7), inserts always target the rightmost leaf page, triggering PostgreSQL's optimized "fastpath" right-side split that creates a new empty page while keeping existing pages at ~90% density. With random UUIDv4, inserts target arbitrary pages throughout the index, causing mid-page splits that leave both halves approximately 50% full. Credativ's December 2025 analysis of 1 million rows found zero physically contiguous leaf pages with UUIDv4 versus 99.5% contiguous with UUIDv7. Page splits per million records: approximately 1020 for sequential keys versus 5,00010,000+ for random keys — a 500× difference.

WAL amplification is the hidden killer. PostgreSQL's full_page_writes setting (enabled by default, and it should stay on) logs the entire 8 KB page on its first modification after each checkpoint. With BIGSERIAL, sequential inserts repeatedly touch the same few rightmost pages — very few full-page writes occur. With random UUIDs, each insert likely touches a different leaf page that hasn't been modified since the last checkpoint, triggering an 8 KB full-page image for each. Tomas Vondra's benchmarks measured this directly: BIGSERIAL inserts at 5,000/sec generated ~2 GB of WAL per hour; random UUID inserts at the same rate generated ~40 GB — a 20× amplification. Buildkite confirmed this in production, measuring a 50% reduction in WAL rate after switching from random to time-ordered UUIDs.

Neither UUID nor BIGINT triggers TOAST. Both are fixed-length types with PLAIN storage strategy. TOAST activates only when an entire tuple exceeds ~2 KB, which identifier columns alone cannot cause. TEXT-based identifiers use variable-length storage with varlena headers but at typical ID sizes (2437 bytes) contribute negligibly to TOAST thresholds.

PostgreSQL 17 versus 18 for UUIDv7

PostgreSQL 17 does not include native UUIDv7. The feature was pulled before the September 2024 release because RFC 9562 wasn't fully finalized by the feature-freeze deadline. PG17 offers only gen_random_uuid() (UUIDv4) built-in, plus uuid-ossp extension for v1/v3/v5.

PostgreSQL 18 (September 2025) introduced native uuidv7() with several notable design decisions. It uses the rand_a field for sub-millisecond timestamp precision (~250ns on Linux), guaranteeing monotonicity within a single backend session. It also provides uuid_extract_timestamp(uuid) for extracting the embedded timestamp and uuid_extract_version(uuid) for version identification. A useful feature: uuidv7('-1 day'::INTERVAL) shifts the embedded timestamp, enabling backfill of historical data.

For PostgreSQL 17 and earlier, the pg_uuidv7 extension (fboulnois/pg_uuidv7, C-based) provides uuid_generate_v7() with performance matching native gen_random_uuid(). A pure SQL fallback function also exists but lacks sub-millisecond monotonicity guarantees.


Empirical benchmarks: UUIDv7 closes the gap with BIGINT

Multiple independent benchmarks from 20242025 converge on consistent findings. The data comes from reproducible pgbench tests, production case studies, and deep pageinspect analyses.

Insert throughput. Jeremy Schneider's "UUID Benchmark War" (February 2024) tested inserting 1 million rows into a table pre-loaded with 20 million rows using 10 concurrent pgbench clients. Results: BIGINT and UUIDv7 both completed in 290 seconds (~3,450 TPS) — identical throughput. UUIDv4 took 375 seconds (29% slower) and UUID-as-TEXT took 410 seconds (41% slower). The bottleneck for UUIDv4 wasn't CPU but I/O: its primary key index consumed so much buffer cache that it starved other indexes of memory, amplifying the total I/O cost.

Index size and density. Credativ's December 2025 analysis on PostgreSQL 18 found UUIDv7 primary key indexes were 2627% smaller than UUIDv4 indexes for the same data. UUIDv7 leaf pages filled to the standard 90% fillfactor; UUIDv4 leaf pages averaged ~67% density with some pages only half-full. Ardent Performance's measurements at 21 million rows: total storage was 1.97 GB for BIGINT, 2.47 GB for UUIDv7, 2.65 GB for UUIDv4, and 4.31 GB for UUID-as-TEXT.

The index-only scan catastrophe. Cybertec's Ants Aasma demonstrated the most striking finding: an index-only scan across 10 million rows required 27,332 buffer hits with BIGINT versus 8,562,960 buffer hits with random UUIDv4 — a 31,000% increase. This occurs because random UUIDs destroy visibility map locality. With sequential keys, consecutive index entries map to nearby heap pages sharing the same visibility map page. With random UUIDs, consecutive entries scatter across the entire table, causing ~6/7 of visibility map lookups to miss the cache. This added 0.86 to 3.4 seconds of latency to a single aggregate query.

Range queries. Credativ measured ORDER BY id on 1 million rows: UUIDv7 completed in 113ms versus 318ms for UUIDv4 — approximately 3× faster, requiring ~100× fewer buffer hits because UUIDv7 leaf pages are physically contiguous.

Join performance. Cybertec's join benchmark across 5 million rows showed UUID join overhead of +13% versus integer when data fits in memory. The penalty grows dramatically when indexes exceed available RAM because UUID indexes are ~40% larger than BIGINT indexes for the same row count.

Dimension BIGINT UUIDv7 UUIDv4
Insert speed (large table) Baseline ~Same 29% slower
Index size (21M rows) 1.97 GB 2.47 GB 2.65 GB
Buffer hits (10M index scan) 27K ~30K (est.) 8.56M
ORDER BY 1M rows Fastest 113ms 318ms
WAL volume (5K inserts/hr) ~2 GB ~23 GB ~40 GB
Leaf page density ~90% ~90% ~67%

Partitioning with time-ordered identifiers

UUIDv7's embedded timestamp unlocks a powerful capability: time-range partitioning directly on the primary key column, eliminating the need for a separate created_at column and saving 8 bytes per row.

The approach requires a boundary function that constructs the minimum UUIDv7 value for a given timestamp. PostgreSQL 18's uuid_extract_timestamp() enables partition pruning when the planner can determine which partition a UUID falls into. For audit logs and usage events — exactly the time-series tables in the user's 31-table schema — this pattern is ideal:

CREATE TABLE usage_events (
  id UUID PRIMARY KEY DEFAULT uuidv7(),
  tenant_id UUID NOT NULL,
  event_type TEXT NOT NULL,
  payload JSONB
) PARTITION BY RANGE (id);

-- Monthly partitions using UUIDv7 boundary values
CREATE TABLE usage_events_2026_01 PARTITION OF usage_events
  FOR VALUES FROM (uuidv7_boundary('2026-01-01'))
  TO (uuidv7_boundary('2026-02-01'));

Dropping old partitions is an O(1) metadata operation versus DELETE which generates massive WAL. Time-range queries benefit from partition pruning even when filtering by the UUID column rather than a timestamp.

BIGSERIAL partitioning requires estimating ID ranges based on insert rate, producing uneven partitions when throughput varies. UUIDv4 cannot be range-partitioned by time at all — only HASH partitioning works, which distributes data evenly but doesn't support time-based lifecycle management.

For distributed databases (CockroachDB, Spanner), the calculus inverts. These systems distribute data across nodes by key range. Time-ordered keys like UUIDv7 concentrate all recent writes on a single node, creating hotspots. CockroachDB explicitly recommends UUIDv4 for this reason, and Spanner offers bit-reversed sequences as an alternative to monotonic keys.


The hybrid architecture: BIGSERIAL inside, UUID outside

The hybrid pattern — BIGSERIAL as the true primary key for internal operations, with a UUID column exposed via APIs — remains the gold standard for systems that need both maximum join performance and enumeration-resistant public identifiers. Stripe, Instagram, and Clerk all use variants of this approach.

The schema pattern is straightforward: BIGSERIAL primary key, UUID unique column with a default, and all foreign keys referencing the integer PK. This keeps FK indexes at 8 bytes per entry (versus 16 for UUID), maintaining roughly 50% smaller index sizes and enabling pass-by-value Datum handling for all join comparisons.

CREATE TABLE users (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  public_id UUID NOT NULL DEFAULT uuidv7(),
  email TEXT NOT NULL
);
CREATE UNIQUE INDEX idx_users_public_id ON users (public_id);

The API layer translates at the boundary: controllers accept the UUID, look up the row, and never expose the internal integer. Stripe enhances this with type-prefixed IDs (cus_, ch_, pi_) that encode resource type in the identifier string itself. The TypeID project formalizes this pattern using UUIDv7 + type prefix in Base32 encoding.

The trade-off is real: every table carries an extra 16 bytes per row plus a unique index, and the application must maintain translation discipline. For a 31-table schema, this adds meaningful storage overhead and code complexity. The question is whether that overhead is justified by the use case.

When to choose hybrid over pure UUIDv7: When foreign key join performance is critical and the workload involves complex multi-table joins across large datasets. The 8-byte BIGINT advantage compounds across every FK column, every secondary index containing those FKs, and every join buffer.

When pure UUIDv7 is sufficient: When the workload is primarily insert-heavy or lookup-by-PK, distributed generation matters, or the schema has relatively few cross-table joins. UUIDv7's insert performance matches BIGINT, and its 16-byte size overhead is manageable for most applications.


Security: which identifiers resist enumeration?

OWASP ranks Broken Object Level Authorization (BOLA) as #1 in the API Security Top 10. Identifier opacity is a defense-in-depth measure — never the primary defense, which must always be proper authorization checks.

BIGSERIAL is the worst offender. Sequential IDs expose total record count, creation order, growth rate, and enable trivial iteration. An attacker accessing /api/users/1000 can immediately try /api/users/1001. UUIDv4 provides the strongest enumeration resistance with 122 random bits — brute-force guessing is computationally infeasible. UUIDv7 leaks creation timestamps (millisecond precision) but its 74 random bits per millisecond still make guessing infeasible. An attacker knowing the approximate creation time faces 2^74 (~1.9 × 10²²) possibilities.

For systems requiring maximum opacity, three options exist:

  • Pure UUIDv4 for external identifiers (no timestamp leakage, maximum randomness)
  • CUID2 (SHA-3 hashing deliberately destroys all input patterns)
  • Sqids/Hashids as an obfuscation layer over BIGSERIAL (bidirectional encoding, but not cryptographically secure — determined attackers can reverse-engineer the alphabet)

The practical recommendation: use UUIDv7 internally for database performance benefits, and if timestamp leakage in external URLs is unacceptable, either expose a separate UUIDv4 column or apply the hybrid pattern with the UUID serving as the external identifier.


Cross-system merging and distributed generation

The strongest argument for UUIDs over BIGSERIAL has always been conflict-free merging. When two independent databases generate UUIDs, they can be merged without any key remapping. With BIGSERIAL, merging requires remapping every ID and cascading updates through all foreign key relationships — a painful, error-prone operation on a 31-table schema.

UUIDv7 specifically excels here because it requires zero coordination between generators. No machine IDs to assign, no sequence ranges to partition, no central authority. Any application instance, in any environment, can generate globally unique time-ordered IDs independently. This makes staging-to-production data promotion trivial: records created in staging carry their UUIDs directly into production.

Snowflake-style IDs achieve distributed uniqueness but require machine ID coordination (10-bit worker assignment), limiting them to environments where a coordination service exists. For serverless, ephemeral, or multi-region architectures, this constraint is often unworkable.

For sharding, UUIDs provide natural distribution: hash the UUID for shard selection and get even distribution across nodes. UUIDv7 with hash-based sharding works well; with range-based sharding, temporal locality means recent data clusters on one shard (which may be desirable or undesirable depending on access patterns).


Industry consensus has shifted, with important caveats

The convergence of RFC 9562 (May 2024), PostgreSQL 18's native uuidv7() (September 2025), Python 3.14's uuid.uuid7(), Ruby 3.3's SecureRandom.uuid_v7, and .NET 9's Guid.CreateVersion7() represents a clear inflection point. Multiple PostgreSQL experts describe this as "almost closing the long-standing debate."

Framework defaults have not changed. Rails, Django, Laravel, and Spring Boot all still default to auto-incrementing integers. UUID adoption remains opt-in. However, all major ORMs now support UUIDv7 either natively or through database-level defaults, and the ecosystem friction has dropped to near zero.

The distributed database exception is critical. CockroachDB's documentation explicitly warns: "Cockroach Labs strongly recommends using UUIDv4. Other types of UUID are largely untested." Google Cloud Spanner similarly recommends against monotonic keys. Marc Brooker (AWS Distinguished Engineer) published criticism of UUIDv7's trade-offs for distributed systems in October 2025, proposing a modified UUIDv8 approach. Time-ordered keys create write hotspots in range-distributed systems because all recent inserts target the same key range.

Production adoption is strong. Buildkite migrated to UUIDv7 as the sole PK for all new tables, measuring 50% WAL reduction. Shopify adopted ULIDs for payment idempotency keys, reporting 50% faster INSERT performance versus UUIDv4. Stripe uses custom type-prefixed IDs. Clerk uses KSUIDs with type prefixes. The pattern is clear: time-ordered, globally unique identifiers have won for single-node and lightly-distributed relational databases.


Conclusion: a decision framework for 31 tables on BIGSERIAL

The research points to a clear decision tree. If your database is single-node PostgreSQL with no federation, merging, or distributed generation requirements, BIGSERIAL (or GENERATED ALWAYS AS IDENTITY) remains excellent — it's the most storage-efficient option with the best join performance, and there's no compelling reason to undertake a migration purely for performance. Add a UUIDv7 column for external API exposure if enumeration resistance matters.

If you need distributed ID generation, cross-system merging, or client-side ID creation, UUIDv7 is the clear choice. Its insert performance matches BIGINT, its index behavior is dramatically better than UUIDv4, and PostgreSQL 18's native support eliminates the extension dependency. The 8-byte-per-column overhead versus BIGINT is the primary cost, compounding across foreign keys and secondary indexes.

If you need both maximum join performance and globally unique external identifiers, the hybrid pattern (BIGINT PK + UUIDv7 unique column) provides the best of both worlds at the cost of schema complexity and storage overhead.

Three things to avoid: random UUIDv4 as primary keys (the performance penalty is severe and well-documented), TEXT-encoded UUIDs (37+ bytes versus 16 for native UUID type), and Snowflake IDs in serverless environments (machine ID coordination doesn't fit the model). For time-series tables like audit logs and usage events, UUIDv7 with range partitioning directly on the PK column is the strongest pattern available — it eliminates the need for a separate timestamp column while enabling efficient partition lifecycle management.

The industry trajectory is unmistakable. UUIDv7 is the new default recommendation for new single-node relational database applications. The remaining question for existing systems is whether the migration cost is justified by the specific benefits — and for a 31-table BIGSERIAL schema that works well today, the answer may be to adopt UUIDv7 for new tables while leaving existing ones unchanged, unless federation or distributed generation becomes a requirement.