Schemint

paste PostgreSQL or JSON, get your schema reviewed like a senior would

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

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.

Paste both variants into Schemint and compare what comes out: entity, records, TypeScript and the Flyway migration all follow the key type you chose.

Related