Primary Keys and Row Identity
Rule
Give every table id uuid DEFAULT uuidv7() PRIMARY KEY; enforce natural keys as unique constraints, never as primary keys.
Why
UUIDv7 values are time-ordered, so they index like sequential keys while staying globally unique, non-enumerable, and safe to expose in URLs and APIs. Surrogate keys keep identity stable when business attributes change.
Do
- Declare
id uuid DEFAULT uuidv7() PRIMARY KEYon every new table. - Expose the
iddirectly in APIs and URLs by default; no second public identifier is needed. - When the product requires prefixed identifiers (
usr_V1StGXR8Z5), addpublic_id text NOT NULLwith a unique constraint: the type prefix plus an independently generated random suffix. The uuididstays internal for every FK; APIs and URLs then expose onlypublic_id. - Enforce natural keys (email, SKU, external reference) with unique constraints; see index basics for constraint vs index.
- Use
bigint GENERATED ALWAYS AS IDENTITYinstead only when key compactness or extreme insert concurrency demonstrably matters, and note why in the migration. - Give junction tables their own
idplus a unique constraint over the pair of foreign keys.
Avoid
- Do not use
serialorbigserial; identity columns replaced them. - Do not use random v4 UUIDs (
gen_random_uuid()) as primary keys; random inserts fragment the B-tree. - Do not use natural keys as primary keys, even “stable” ones; emails change and codes get recycled, and the change ripples through every foreign key.
- Do not use composite primary keys on business tables; use
idplus a unique constraint. - Do not mix key strategies within a schema without a documented reason.
- Do not derive
public_idfrom the PK bits (TypeID-style encoding) or the PK frompublic_id; the two are independent values joined by an indexed lookup.
Example
CREATE TABLE products (
id uuid DEFAULT uuidv7() PRIMARY KEY,
sku text NOT NULL,
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT products_sku_key UNIQUE (sku)
);
Version Notes
uuidv7()requires PostgreSQL 18. On older targets, generate UUIDv7 values in the application, or fall back tobigint GENERATED ALWAYS AS IDENTITY(PG10+) when application-side generation is not practical.
Exceptions
- High-write-concurrency tables can contend on the rightmost index leaf with time-ordered keys; if measured,
bigintidentity or fillfactor tuning is the escape hatch. - Static lookup tables (see enums, domains, and lookup tables) still get
idkeys; their natural codes stay unique-constrained columns.