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

Tables without a primary key: what actually goes wrong

A table without a primary key works. It takes INSERTs, answers SELECTs, passes the test. The problems arrive later, always in a group:

The cleanup, when it's already too late

Deduplicating a PK-less table is its own small ordeal, and it teaches why the key should have existed. The standard trick leans on ctid, the physical row address:

DELETE FROM events a
USING events b
WHERE a.ctid < b.ctid
  AND a.event_type = b.event_type
  AND a.payload = b.payload
  AND a.created_at = b.created_at;

Every column has to participate in the comparison (which is exactly the "REPLICA IDENTITY FULL" problem in another costume), the query is a full self-join, and ctid isn't stable across VACUUM FULL, so this only works while nothing else moves the rows. An id column would have made it a one-line GROUP BY.

"But it's a log/staging table"

The legitimate case exists: bulk-load staging, an append-only table nobody updates. Even there, an id BIGINT GENERATED ALWAYS AS IDENTITY costs 8 bytes per row and gives you back addressability, stable keyset pagination (WHERE id > $last ORDER BY id LIMIT 100 doesn't skip or repeat rows the way OFFSET does under concurrent writes), and the option to deduplicate. The cost of adding it later, with the table huge and production live, is much higher than being born with it: ALTER TABLE ... ADD COLUMN id BIGINT GENERATED ALWAYS AS IDENTITY rewrites the whole table under an exclusive lock.

CREATE TABLE events (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payload JSONB NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Natural vs technical keys

A natural PK (national id, email, external code) deserves suspicion: a value that comes from outside changes, and changing a PK drags FKs, indexes and caches along. The email that was "obviously unique and stable" meets its first account merge, the national id meets its first typo correction, and now the correction is a multi-table migration. A technical key (identity/UUID) plus a UNIQUE on the natural key gives the same effect without the coupling: the uniqueness rule is enforced, the join key never changes. Whether that technical key should be a BIGINT or a UUID is its own trade-off; either beats not having one.

A table without a PK is one of the errors Schemint marks as an error (not a warning) when it analyzes your DDL, before generating the Entity and migration.

Related