UUID vs BIGINT primary keys in PostgreSQL
Both work. BIGINT identity keys are what Postgres does best; UUIDs solve problems BIGINT can't. The mistake is picking by fashion instead of by which problems you actually have.
BIGINT: the boring default
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY
);
Eight bytes. Sequential, so new rows land at the right edge of the B-tree index: pages fill up and stay full, cache locality is excellent, and inserts don't churn already-written pages. Joins and FKs carry 8-byte values around. For a table other tables reference a lot, that compactness multiplies, every FK column and every FK index inherits the key's size.
Two real drawbacks. Ids are guessable: /orders/1041 invites
someone to try /orders/1042, and the count leaks business volume
(the classic German tank problem). And ids are generated by one database, which
gets awkward if clients must create ids offline or several systems mint ids for
the same logical space.
UUIDv4: random, and you pay for random
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
);
Sixteen bytes, twice the BIGINT, in the PK, every FK and every index on either. The bigger cost is the randomness itself: each insert lands on a random B-tree page. On a table of any size that means the working set is the whole index, pages split constantly, and write-ahead volume grows (full-page writes on pages that would otherwise stay cold). None of this matters at thousands of rows; all of it matters at hundreds of millions.
What you buy: ids anyone can generate anywhere without coordination, no enumeration, no volume leak, and safe merging of datasets from different sources.
UUIDv7: the middle ground that usually wins
UUIDv7 puts a millisecond timestamp in the high bits and randomness in the
rest: still globally unique and non-coordinated, but time-ordered, so inserts
go back to filling the right edge of the index like a BIGINT does. Postgres 18
ships uuidv7(); on earlier versions the application generates it
(libraries exist for every stack) and the column stays plain UUID:
CREATE TABLE orders (
id UUID PRIMARY KEY -- app supplies UUIDv7
);
You keep the 16-byte cost and gain back the locality. One caveat inherited from the timestamp bits: ids reveal creation time, and sort roughly by it. If that's a leak in your domain, v4 it is.
Choosing
- Internal service, ids never leave your systems: BIGINT. Smallest, fastest, replication-friendly, nothing to explain.
- Ids in URLs, multi-writer id creation, offline clients, cross-system merges: UUID, prefer v7 unless creation-time leakage is a problem, then v4 and accept the index behavior.
- Volume leak is the only concern: BIGINT internally plus an opaque public handle (a v7 UUID or a random slug) in a UNIQUE column. Two keys, each doing the job it's good at.
Whatever you pick, keep it consistent across the schema; a graph where half the FKs are 8 bytes and half are 16 for no stated reason is a review comment waiting to happen. And the choice doesn't remove the need for a key at all, a table without any primary key fails in worse ways than either option here.
In the generated code
The type flows through everything: UUID becomes
java.util.UUID in the JPA entity, string (with
.uuid() validation in Zod) in TypeScript, UUID in
Pydantic. BIGINT becomes Long, and in TypeScript it's worth
mapping to string if ids can exceed 2^53, JSON numbers lose
precision past that.