Findings
Errors (2)
No primary key
Without a primary key rows are not individually addressable: no safe UPDATE/DELETE of one row, no logical replication, ORMs misbehave, and duplicates are legal.
Fix Add a natural composite key or a surrogate id.
ALTER TABLE campaign_media ADD PRIMARY KEY (...);
No primary key
Without a primary key rows are not individually addressable: no safe UPDATE/DELETE of one row, no logical replication, ORMs misbehave, and duplicates are legal.
Fix Add a natural composite key or a surrogate id.
ALTER TABLE settings ADD PRIMARY KEY (...);
Warnings (7)
Foreign key without index
PostgreSQL does not index FK columns automatically. Every DELETE/UPDATE on media must scan campaign_media to check the constraint, and every join from the parent side is a sequential scan. The scan cost grows with the child table forever, so this gets worse on its own.
An index is not free. Every write to campaign_media maintains it — insert, update and delete alike — and it takes disk. Weigh that against how often media rows are actually deleted or re-keyed — if the answer is never, the scan never happens and the index only costs.
Fix Add a partial index, scoped to the rows that have a value. media_id is nullable, so the NULL rows are not indexed at all and the write cost is a fraction of a full index, while the constraint check still uses it.
CREATE INDEX CONCURRENTLY idx_campaign_media_media_id ON campaign_media(media_id) WHERE media_id IS NOT NULL;
Foreign key without index
PostgreSQL does not index FK columns automatically. Every DELETE/UPDATE on templates must scan campaigns to check the constraint, and every join from the parent side is a sequential scan. The scan cost grows with the child table forever, so this gets worse on its own.
An index is not free. Every write to campaigns maintains it — insert, update and delete alike — and it takes disk. Weigh that against how often templates rows are actually deleted or re-keyed — if the answer is never, the scan never happens and the index only costs.
Fix Add a partial index, scoped to the rows that have a value. template_id is nullable, so the NULL rows are not indexed at all and the write cost is a fraction of a full index, while the constraint check still uses it.
CREATE INDEX CONCURRENTLY idx_campaigns_template_id ON campaigns(template_id) WHERE template_id IS NOT NULL;
Foreign key without index
PostgreSQL does not index FK columns automatically. Every DELETE/UPDATE on templates must scan campaigns to check the constraint, and every join from the parent side is a sequential scan. The scan cost grows with the child table forever, so this gets worse on its own.
An index is not free. Every write to campaigns maintains it — insert, update and delete alike — and it takes disk. Weigh that against how often templates rows are actually deleted or re-keyed — if the answer is never, the scan never happens and the index only costs.
Fix Add a partial index, scoped to the rows that have a value. archive_template_id is nullable, so the NULL rows are not indexed at all and the write cost is a fraction of a full index, while the constraint check still uses it.
CREATE INDEX CONCURRENTLY idx_campaigns_archive_template_id ON campaigns(archive_template_id) WHERE archive_template_id IS NOT NULL;
F021media content_typeundocumented-enum Enum-like column with no CHECK
content_type is a short string that clearly takes a fixed set of values, but the database accepts anything. Typos become new states, and nobody can list the legal values without reading application code.
Fix Add a CHECK constraint (cheap, easy to evolve) or a lookup table if values need metadata. Avoid native ENUM types unless the set is truly frozen.
ALTER TABLE media ADD CONSTRAINT media_content_type_check CHECK (content_type IN (...));
F006roles list_idfk-index Foreign key without index
PostgreSQL does not index FK columns automatically. Every DELETE/UPDATE on lists must scan roles to check the constraint, and every join from the parent side is a sequential scan. The scan cost grows with the child table forever, so this gets worse on its own.
An index is not free. Every write to roles maintains it — insert, update and delete alike — and it takes disk. Weigh that against how often lists rows are actually deleted or re-keyed — if the answer is never, the scan never happens and the index only costs.
Fix Add a partial index, scoped to the rows that have a value. list_id is nullable, so the NULL rows are not indexed at all and the write cost is a fraction of a full index, while the constraint check still uses it.
CREATE INDEX CONCURRENTLY idx_roles_list_id ON roles(list_id) WHERE list_id IS NOT NULL;
F007users user_role_idfk-index Foreign key without index
PostgreSQL does not index FK columns automatically. Every DELETE/UPDATE on roles must scan users to check the constraint, and every join from the parent side is a sequential scan. The scan cost grows with the child table forever, so this gets worse on its own.
An index is not free. Every write to users maintains it — insert, update and delete alike — and it takes disk. Weigh that against how often roles rows are actually deleted or re-keyed — if the answer is never, the scan never happens and the index only costs.
Fix Add an index on the FK column(s).
CREATE INDEX CONCURRENTLY idx_users_user_role_id ON users(user_role_id);
F008users list_role_idfk-index Foreign key without index
PostgreSQL does not index FK columns automatically. Every DELETE/UPDATE on roles must scan users to check the constraint, and every join from the parent side is a sequential scan. The scan cost grows with the child table forever, so this gets worse on its own.
An index is not free. Every write to users maintains it — insert, update and delete alike — and it takes disk. Weigh that against how often roles rows are actually deleted or re-keyed — if the answer is never, the scan never happens and the index only costs.
Fix Add a partial index, scoped to the rows that have a value. list_role_id is nullable, so the NULL rows are not indexed at all and the write cost is a fraction of a full index, while the constraint check still uses it.
CREATE INDEX CONCURRENTLY idx_users_list_role_id ON users(list_role_id) WHERE list_role_id IS NOT NULL;
Notes (20)
Nullable foreign key
bounces.campaign_id may be NULL, so the relationship to campaigns is optional. That is legitimate (e.g. approved_by before approval) but often a modelling shrug: a row with no owner, or two nullable FKs that are secretly an either/or.
Fix Confirm the optionality is a domain rule; document it in the column comment.
Nullable foreign key
campaign_lists.list_id may be NULL, so the relationship to lists is optional. That is legitimate (e.g. approved_by before approval) but often a modelling shrug: a row with no owner, or two nullable FKs that are secretly an either/or.
Fix Confirm the optionality is a domain rule; document it in the column comment.
Nullable foreign key
campaign_media.campaign_id may be NULL, so the relationship to campaigns is optional. That is legitimate (e.g. approved_by before approval) but often a modelling shrug: a row with no owner, or two nullable FKs that are secretly an either/or.
Fix Confirm the optionality is a domain rule; document it in the column comment.
Nullable foreign key
campaign_media.media_id may be NULL, so the relationship to media is optional. That is legitimate (e.g. approved_by before approval) but often a modelling shrug: a row with no owner, or two nullable FKs that are secretly an either/or.
Fix Confirm the optionality is a domain rule; document it in the column comment.
Either/or foreign keys without a CHECK
campaign_media has several nullable FKs (campaign_id, media_id) that look like an exclusive arc — a row should point at exactly one of them. Nothing stops zero or both.
Fix Add a CHECK that exactly one is non-null, or restructure with a supertype.
ALTER TABLE campaign_media ADD CONSTRAINT campaign_media_one_target CHECK ((campaign_id IS NOT NULL)::int + (media_id IS NOT NULL)::int = 1);
Nullable foreign key
campaign_views.subscriber_id may be NULL, so the relationship to subscribers is optional. That is legitimate (e.g. approved_by before approval) but often a modelling shrug: a row with no owner, or two nullable FKs that are secretly an either/or.
Fix Confirm the optionality is a domain rule; document it in the column comment.
Nullable foreign key
campaigns.template_id may be NULL, so the relationship to templates is optional. That is legitimate (e.g. approved_by before approval) but often a modelling shrug: a row with no owner, or two nullable FKs that are secretly an either/or.
Fix Confirm the optionality is a domain rule; document it in the column comment.
Nullable foreign key
campaigns.archive_template_id may be NULL, so the relationship to templates is optional. That is legitimate (e.g. approved_by before approval) but often a modelling shrug: a row with no owner, or two nullable FKs that are secretly an either/or.
Fix Confirm the optionality is a domain rule; document it in the column comment.
F022campaigns template_id, archive_template_idexclusive-arc Either/or foreign keys without a CHECK
campaigns has several nullable FKs (template_id, archive_template_id) that look like an exclusive arc — a row should point at exactly one of them. Nothing stops zero or both.
Fix Add a CHECK that exactly one is non-null, or restructure with a supertype.
ALTER TABLE campaigns ADD CONSTRAINT campaigns_one_target CHECK ((template_id IS NOT NULL)::int + (archive_template_id IS NOT NULL)::int = 1);
Hub table: referenced by 5 tables
Any change to its key, its delete semantics, or its partitioning touches every dependent. Migrations on hub tables need the longest lock windows and the most careful rollout.
Fix Treat schema changes here as breaking changes with a written rollout plan.
Nullable foreign key
link_clicks.campaign_id may be NULL, so the relationship to campaigns is optional. That is legitimate (e.g. approved_by before approval) but often a modelling shrug: a row with no owner, or two nullable FKs that are secretly an either/or.
Fix Confirm the optionality is a domain rule; document it in the column comment.
Nullable foreign key
link_clicks.subscriber_id may be NULL, so the relationship to subscribers is optional. That is legitimate (e.g. approved_by before approval) but often a modelling shrug: a row with no owner, or two nullable FKs that are secretly an either/or.
Fix Confirm the optionality is a domain rule; document it in the column comment.
F024link_clicks campaign_id, subscriber_idexclusive-arc Either/or foreign keys without a CHECK
link_clicks has several nullable FKs (campaign_id, subscriber_id) that look like an exclusive arc — a row should point at exactly one of them. Nothing stops zero or both.
Fix Add a CHECK that exactly one is non-null, or restructure with a supertype.
ALTER TABLE link_clicks ADD CONSTRAINT link_clicks_one_target CHECK ((campaign_id IS NOT NULL)::int + (subscriber_id IS NOT NULL)::int = 1);
F018roles parent_idfk-nullable Nullable foreign key
roles.parent_id may be NULL, so the relationship to roles is optional. That is legitimate (e.g. approved_by before approval) but often a modelling shrug: a row with no owner, or two nullable FKs that are secretly an either/or.
Fix Confirm the optionality is a domain rule; document it in the column comment.
F019roles list_idfk-nullable Nullable foreign key
roles.list_id may be NULL, so the relationship to lists is optional. That is legitimate (e.g. approved_by before approval) but often a modelling shrug: a row with no owner, or two nullable FKs that are secretly an either/or.
Fix Confirm the optionality is a domain rule; document it in the column comment.
F025roles parent_id, list_idexclusive-arc Either/or foreign keys without a CHECK
roles has several nullable FKs (parent_id, list_id) that look like an exclusive arc — a row should point at exactly one of them. Nothing stops zero or both.
Fix Add a CHECK that exactly one is non-null, or restructure with a supertype.
ALTER TABLE roles ADD CONSTRAINT roles_one_target CHECK ((parent_id IS NOT NULL)::int + (list_id IS NOT NULL)::int = 1);
TIMESTAMP without time zone
created_at stores wall-clock time with no zone. It reads back differently depending on the session's TimeZone, and DST transitions produce ambiguous values.
Fix Use TIMESTAMPTZ.
ALTER TABLE sessions ALTER COLUMN created_at TYPE timestamptz;
Isolated table
sessions references nothing and nothing references it. Either it is a staging/log table (fine, say so), or it is dead, or it is the seed of a second data model growing beside the first.
Fix Document its purpose or drop it.
Isolated table
settings references nothing and nothing references it. Either it is a staging/log table (fine, say so), or it is dead, or it is the seed of a second data model growing beside the first.
Fix Document its purpose or drop it.
F020users list_role_idfk-nullable Nullable foreign key
users.list_role_id may be NULL, so the relationship to roles is optional. That is legitimate (e.g. approved_by before approval) but often a modelling shrug: a row with no owner, or two nullable FKs that are secretly an either/or.
Fix Confirm the optionality is a domain rule; document it in the column comment.