Listmonk

The schema behind listmonk, a self-hosted newsletter and mailing-list manager: subscribers and the lists they belong to, campaigns and their deliveries, media, bounces, and the users and roles that run it.

16 tables · 126 columns · 19 foreign keys · 5 domains  |  2 errors · 7 warnings · 20 notes

Generated from listmonk@v6.2.0.sql on 2026-09-05.

Conventions

Conventions are claims. Findings below are where the schema breaks them.

Findings

Errors (2)

F001campaign_mediaprimary-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 campaign_media ADD PRIMARY KEY (...);
F002settingsprimary-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)

F005campaign_media media_idfk-index

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;
F003campaigns template_idfk-index

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;
F004campaigns archive_template_idfk-index

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)

F017bounces campaign_idfk-nullable

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.

F011campaign_lists list_idfk-nullable

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.

F013campaign_media campaign_idfk-nullable

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.

F014campaign_media media_idfk-nullable

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.

F023campaign_media campaign_id, media_idexclusive-arc

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);
F012campaign_views subscriber_idfk-nullable

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.

F009campaigns template_idfk-nullable

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.

F010campaigns archive_template_idfk-nullable

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);
F029campaignsblast-radius

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.

F015link_clicks campaign_idfk-nullable

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.

F016link_clicks subscriber_idfk-nullable

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);
F026sessions created_attimestamp-tz

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;
F028sessionsorphan-table

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.

F027settingsorphan-table

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.

Schema

Schema map: 16 tables, 19 foreign keys, 5 domains. Click to open the 3D explorer.Audiencelistssubscriberssubscriber_listsbouncesCampaignslinkstemplatescampaignscampaign_listscampaign_viewslink_clicksMediamediacampaign_mediaUsers and rolessessionsrolesusersSettingssettings

16 tables · 19 foreign keys · 5 domains. Open the 3D explorer: rotate, zoom, click any table or relationship.

Audience 4 tables

Subscribers, lists, the subscription rows that join them with a status, and the bounces recorded against a subscriber.

Entity-relationship diagram: subscribers, lists, subscriber_lists, bouncessubscribersid PKserialuuiduuidemailtextnametextattribsjsonbstatussubscriber_statuscreated_attimestamptzupdated_attimestamptzlistsid PKserialuuiduuidnametexttypelist_typeoptinlist_optinstatuslist_statustagsvarchar(100)[]descriptiontextcreated_attimestamptzupdated_attimestamptzsubscriber_listssubscriber_id PKintegerlist_id PKintegermetajsonbstatussubscription_statuscreated_attimestamptzupdated_attimestamptzbouncesid PKserialsubscriber_id FKintegercampaign_id FKintegertypebounce_typesourcetextmetajsonbcreated_attimestamptzcampaignssubscriber_idcampaign_idsubscriber_idlist_id

Relationships

subscribers

8 cols · no RLS · line 21View in 3D
ColumnTypeNullDefaultReferencesNotes
id PKserialNOT NULL
uuid UKuuidNOT NULL
email UKtextNOT NULL
name textNOT NULL
attribs jsonbNOT NULL'{}'
status subscriber_statusNOT NULL'enabled'
created_at timestamptznullnow()
updated_at timestamptznullnow()

Indexes

  • UNIQUE (lower(email)) idx_subs_email
  • (status) idx_subs_status
  • (id, status) idx_subs_id_status
  • (created_at) idx_subs_created_at
  • (updated_at) idx_subs_updated_at

Referenced by

lists

10 cols · no RLS · line 40View in 3D
ColumnTypeNullDefaultReferencesNotes
id PKserialNOT NULL
uuid UKuuidNOT NULL
name textNOT NULL
type list_typeNOT NULL
optin list_optinNOT NULL'single'
status list_statusNOT NULL'active'
tags varchar(100)[]null
description textNOT NULL''
created_at timestamptznullnow()
updated_at timestamptznullnow()

Indexes

  • (type) idx_lists_type
  • (optin) idx_lists_optin
  • (status) idx_lists_status
  • (name) idx_lists_name
  • (created_at) idx_lists_created_at
  • (updated_at) idx_lists_updated_at

Referenced by

subscriber_lists

6 cols · no RLS · line 62View in 3D
ColumnTypeNullDefaultReferencesNotes
subscriber_id PKintegerNOT NULLsubscribers.id 1:N · CASCADE
list_id PKintegerNOT NULLlists.id 1:N · CASCADE
meta jsonbNOT NULL'{}'
status subscription_statusNOT NULL'unconfirmed'
created_at timestamptznullnow()
updated_at timestamptznullnow()

Indexes

  • (subscriber_id) idx_sub_lists_sub_id
  • (list_id) idx_sub_lists_list_id
  • (status) idx_sub_lists_status

Referenced by

  • nothing

bounces

7 cols · no RLS · line 306View in 3D
  • Nullable foreign key campaign_id — 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.
ColumnTypeNullDefaultReferencesNotes
id PKserialNOT NULL
subscriber_id integerNOT NULLsubscribers.id 1:N · CASCADE
campaign_id integernullcampaigns.id 1:N · SET NULL
type bounce_typeNOT NULL'hard'
source textNOT NULL''
meta jsonbNOT NULL'{}'
created_at timestamptznullnow()

Indexes

  • (subscriber_id) idx_bounces_sub_id
  • (campaign_id) idx_bounces_camp_id
  • (source) idx_bounces_source
  • (created_at) idx_bounces_date

Referenced by

  • nothing

Campaigns 6 tables

Campaigns, the lists they target, the templates they render with, and the views and link clicks they earn.

Entity-relationship diagram: campaigns, campaign_lists, templates, campaign_views, links, link_clickscampaignsid PKserialuuiduuidnametextsubjecttextfrom_emailtextbodytextbody_sourcetextaltbodytextcontent_typecontent_typesend_attimestamptzheadersjsonbattribsjsonbstatuscampaign_statustagsvarchar(100)[]typecampaign_typemessengertexttemplate_id FKintegerto_sendintegersentintegermax_subscriber_idintegerlast_subscriber_idintegerarchivebooleanarchive_slugtextarchive_template_id FKintegerarchive_metajsonbstarted_attimestamptzcreated_attimestamptzupdated_attimestamptzcampaign_listsid PKbigserialcampaign_id FKintegerlist_id FKintegerlist_nametexttemplatesid PKserialnametexttypetemplate_typesubjecttextbodytextbody_sourcetextis_defaultbooleancreated_attimestamptzupdated_attimestamptzcampaign_viewsid PKbigserialcampaign_id FKintegersubscriber_id FKintegercreated_attimestamptzlinksid PKserialuuiduuidurltextcreated_attimestamptzlink_clicksid PKbigserialcampaign_id FKintegerlink_id FKintegersubscriber_id FKintegercreated_attimestamptzlistssubscriberstemplate_idarchive_template_idcampaign_idlist_idcampaign_idsubscriber_idcampaign_idlink_idsubscriber_id

Relationships

campaigns

28 cols · no RLS · line 96View in 3D
  • Foreign key without index template_id — 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.
  • Foreign key without index archive_template_id — 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.
  • Nullable foreign key template_id — 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.
  • Nullable foreign key archive_template_id — 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.
  • Either/or foreign keys without a CHECK template_id, archive_template_id — 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.
  • 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.
ColumnTypeNullDefaultReferencesNotes
id PKserialNOT NULL
uuid UKuuidNOT NULL
name textNOT NULL
subject textNOT NULL
from_email textNOT NULL
body textNOT NULL
body_source textnull
altbody textnull
content_type content_typeNOT NULL'richtext'
send_at timestamptznull
headers jsonbNOT NULL'[]'
attribs jsonbNOT NULL'{}'
status campaign_statusNOT NULL'draft'
tags varchar(100)[]null
type campaign_typenull'regular'
messenger textNOT NULL
template_id integernulltemplates.id 1:N · SET NULL
to_send integerNOT NULL0
sent integerNOT NULL0
max_subscriber_id integerNOT NULL0
last_subscriber_id integerNOT NULL0
archive booleanNOT NULLFALSE
archive_slug UKtextnull
archive_template_id integernulltemplates.id 1:N · SET NULL
archive_meta jsonbNOT NULL'{}'
started_at timestamptznull
created_at timestamptznullnow()
updated_at timestamptznullnow()

Indexes

  • (status) idx_camps_status
  • (name) idx_camps_name
  • (created_at) idx_camps_created_at
  • (updated_at) idx_camps_updated_at

Referenced by

campaign_lists

4 cols · no RLS · line 143View in 3D
  • Nullable foreign key list_id — 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.
ColumnTypeNullDefaultReferencesNotes
id PKbigserialNOT NULL
campaign_id integerNOT NULLcampaigns.id 1:N · CASCADE
list_id integernulllists.id 1:N · SET NULL
list_name textNOT NULL''

Indexes

  • UNIQUE (campaign_id, list_id)
  • (campaign_id) idx_camp_lists_camp_id
  • (list_id) idx_camp_lists_list_id

Referenced by

  • nothing

templates

9 cols · no RLS · line 79View in 3D
ColumnTypeNullDefaultReferencesNotes
id PKserialNOT NULL
name textNOT NULL
type template_typeNOT NULL'campaign'
subject textNOT NULL
body textNOT NULL
body_source textnull
is_default booleanNOT NULLFALSE
created_at timestamptznullnow()
updated_at timestamptznullnow()

Indexes

  • UNIQUE (is_default) WHERE is_default = TRUE

Referenced by

campaign_views

4 cols · no RLS · line 157View in 3D
  • Nullable foreign key subscriber_id — 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.
ColumnTypeNullDefaultReferencesNotes
id PKbigserialNOT NULL
campaign_id integerNOT NULLcampaigns.id 1:N · CASCADE
subscriber_id integernullsubscribers.id 1:N · SET NULL
created_at timestamptznullnow()

Indexes

  • (campaign_id) idx_views_camp_id
  • (subscriber_id) idx_views_subscriber_id
  • (created_at) idx_views_date

Referenced by

  • nothing

Media 2 tables

Uploaded files and which campaigns embed them.

Entity-relationship diagram: media, campaign_mediamediaid PKserialuuiduuidprovidertextfilenametextcontent_typetextthumbtextmetajsonbcreated_attimestamptzcampaign_mediacampaign_id FKintegermedia_id FKintegerfilenametextcampaignscampaign_idmedia_id

Relationships

media

8 cols · no RLS · line 171View in 3D
  • Enum-like column with no CHECK content_type — 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.
ColumnTypeNullDefaultReferencesNotes
id PKserialNOT NULL
uuid UKuuidNOT NULL
provider textNOT NULL''
filename textNOT NULL
content_type textNOT NULL'application/octet-stream'
thumb textNOT NULL
meta jsonbNOT NULL'{}'
created_at timestamptznullnow()

Indexes

  • (provider, filename) idx_media_filename

Referenced by

campaign_media

3 cols · no RLS · line 185View in 3D
  • 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.
  • Foreign key without index media_id — 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.
  • Nullable foreign key campaign_id — 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.
  • Nullable foreign key media_id — 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.
  • Either/or foreign keys without a CHECK campaign_id, media_id — 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.
ColumnTypeNullDefaultReferencesNotes
campaign_id integernullcampaigns.id 1:N · CASCADE
media_id integernullmedia.id 1:N · SET NULL
filename textNOT NULL''

Indexes

  • UNIQUE (campaign_id, media_id) idx_camp_media_id
  • (campaign_id) idx_camp_media_camp_id

Referenced by

  • nothing

Users and roles 3 tables

Admin users, the roles that scope what they can do (including per-list roles), and their sessions.

Entity-relationship diagram: users, roles, sessionsusersid PKserialusernametextpassword_loginbooleanpasswordtextemailtextnametextavatartexttypeuser_typeuser_role_id FKintegerlist_role_id FKintegerstatususer_statustwofa_typetwofa_typetwofa_keytextloggedin_attimestamptzcreated_attimestamptzupdated_attimestamptzrolesid PKserialtyperole_typeparent_id FKintegerlist_id FKintegerpermissionstext[]nametextcreated_attimestamptzupdated_attimestamptzsessionsid PKtextdatajsonbcreated_attimestamplistsparent_idlist_iduser_role_idlist_role_id

Relationships

users

16 cols · no RLS · line 337View in 3D
  • Foreign key without index user_role_id — 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.
  • Foreign key without index list_role_id — 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.
  • Nullable foreign key list_role_id — 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.
ColumnTypeNullDefaultReferencesNotes
id PKserialNOT NULL
username UKtextNOT NULL
password_login booleanNOT NULLFALSE
password textnull
email UKtextNOT NULL
name textNOT NULL
avatar textnull
type user_typeNOT NULL'user'
user_role_id integerNOT NULLroles.id 1:N · RESTRICT
list_role_id integernullroles.id 1:N · CASCADE
status user_statusNOT NULL'disabled'
twofa_type twofa_typeNOT NULL'none'
twofa_key textnull
loggedin_at timestamptznull
created_at timestamptznullnow()
updated_at timestamptznullnow()

Indexes

  • none

Referenced by

  • nothing

roles

8 cols · no RLS · line 322View in 3D
  • Foreign key without index list_id — 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.
  • Nullable foreign key parent_id — 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.
  • Nullable foreign key list_id — 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.
  • Either/or foreign keys without a CHECK parent_id, list_id — 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.
ColumnTypeNullDefaultReferencesNotes
id PKserialNOT NULL
type role_typeNOT NULL'user'
parent_id integernullroles.id 1:N · CASCADE
list_id integernulllists.id 1:N · CASCADE
permissions text[]NOT NULL'{}'
name textnull
created_at timestamptznullnow()
updated_at timestamptznullnow()

Indexes

  • UNIQUE (parent_id, list_id) idx_roles
  • UNIQUE (type, name) WHERE name IS NOT NULL idx_roles_name

Referenced by

sessions

3 cols · no RLS · line 358View in 3D
  • TIMESTAMP without time zone created_at — 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.
  • 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.
ColumnTypeNullDefaultReferencesNotes
id PKtextNOT NULL
data jsonbNOT NULLCAST('{}' AS jsonb)
created_at timestampNOT NULLnow()

Indexes

  • (id, created_at) idx_sessions

Referenced by

  • nothing

Settings 1 tables

The key-value settings table.

Entity-relationship diagram: settingssettingskeytextvaluejsonbupdated_attimestamptz

Relationships

No foreign keys in this domain.

settings

3 cols · no RLS · line 224View in 3D
  • 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.
  • 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.
ColumnTypeNullDefaultReferencesNotes
key UKtextNOT NULL
value jsonbNOT NULL'{}'
updated_at timestamptznullnow()

Indexes

  • (key) idx_settings_key

Referenced by

  • nothing