The schema behind Miniflux, a minimalist feed reader, rebuilt from the migration strings in its Go source: users and how they sign in, the feeds and categories they follow, the entries fetched for them, and a few system tables.
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.
PostgreSQL does not index FK columns automatically. Every DELETE/UPDATE on entries must scan enclosures 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 enclosures maintains it — insert, update and delete alike — and it takes disk. Weigh that against how often entries 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_enclosures_entry_id ON enclosures(entry_id);
mime_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 enclosures ADD CONSTRAINT enclosures_mime_type_check CHECK (mime_type IN (...));
PostgreSQL does not index FK columns automatically. Every DELETE/UPDATE on icons must scan feed_icons 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 feed_icons maintains it — insert, update and delete alike — and it takes disk. Weigh that against how often icons 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_feed_icons_icon_id ON feed_icons(icon_id);
PostgreSQL does not index FK columns automatically. Every DELETE/UPDATE on categories must scan feeds 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 feeds maintains it — insert, update and delete alike — and it takes disk. Weigh that against how often categories 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_feeds_category_id ON feeds(category_id);
mime_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 icons ADD CONSTRAINT icons_mime_type_check CHECK (mime_type IN (...));
PostgreSQL does not index FK columns automatically. Every DELETE/UPDATE on users must scan webauthn_credentials 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 webauthn_credentials maintains it — insert, update and delete alike — and it takes disk. Weigh that against how often users 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_webauthn_credentials_user_id ON webauthn_credentials(user_id);
acme_cache 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.
Tables this wide usually hide several entities (or a JSON column that wants to be one). Every row update rewrites the whole tuple; TOAST kicks in; indexes bloat.
Fix Look for column groups that always change together and split them out.
Tables this wide usually hide several entities (or a JSON column that wants to be one). Every row update rewrites the whole tuple; TOAST kicks in; indexes bloat.
Fix Look for column groups that always change together and split them out.
schema_version 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.
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.
tokens 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.
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.
Tables this wide usually hide several entities (or a JSON column that wants to be one). Every row update rewrites the whole tuple; TOAST kicks in; indexes bloat.
Fix Look for column groups that always change together and split them out.
web_sessions.user_id may be NULL, so the relationship to users 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
16 tables · 14 foreign keys · 4 domains. Open the 3D explorer: rotate, zoom, click any table or relationship.
Users and access 6 tables
Users, their browser and API sessions, API keys, passkeys, and per-user third-party integrations.
Relationships
web_sessions.user_id → users.id — one users, many web_sessions · optional · ON DELETE CASCADE · indexed why: not documented
api_keys.user_id → users.id — one users, many api_keys · required · ON DELETE CASCADE · indexed why: not documented
webauthn_credentials.user_id → users.id — one users, many webauthn_credentials · required · ON DELETE CASCADE · not indexed why: not documented
integrations.user_id → users.id — one users, at most one integrations · required · ON DELETE CASCADE · indexed why: not documented
Hub table: referenced by 8 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.
Wide table (32 columns) — Tables this wide usually hide several entities (or a JSON column that wants to be one). Every row update rewrites the whole tuple; TOAST kicks in; indexes bloat.
Column
Type
Null
Default
References
Notes
idPK
serial
NOT NULL
usernameUK
text
NOT NULL
password
text
null
is_admin
bool
null
'f'
language
text
null
'en_US'
timezone
text
null
'UTC'
theme
text
null
'default'
last_login_at
timestamptz
null
entry_direction
entry_sorting_direction
null
keyboard_shortcuts
boolean
null
entries_per_page
integer
null
show_reading_time
boolean
null
entry_swipe
boolean
null
stylesheet
text
null
google_id
text
null
openid_connect_id
text
null
display_mode
webapp_display_mode
null
entry_order
entry_sorting_order
null
default_reading_speed
integer
null
cjk_reading_speed
integer
null
default_home_page
text
null
categories_sorting_order
text
null
double_tap
boolean
null
mark_read_on_view
boolean
null
media_playback_rate
numeric
null
block_filter_entry_rules
text
null
keep_filter_entry_rules
text
null
mark_read_on_media_player_completion
bool
null
custom_js
text
null
external_font_hosts
text
null
always_open_external_links
bool
null
open_external_links_in_new_tab
bool
null
Indexes
UNIQUE (google_id) WHERE google_id <> ''users_google_id_idx
UNIQUE (openid_connect_id) WHERE openid_connect_id <> ''users_openid_connect_id_idx
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.
Nullable foreign keyuser_id — web_sessions.user_id may be NULL, so the relationship to users 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.
Foreign key without indexuser_id — PostgreSQL does not index FK columns automatically. Every DELETE/UPDATE on users must scan webauthn_credentials 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 webauthn_credentials maintains it — insert, update and delete alike — and it takes disk. Weigh that against how often users rows are actually deleted or re-keyed — if the answer is never, the scan never happens and the index only costs.
Wide table (122 columns) — Tables this wide usually hide several entities (or a JSON column that wants to be one). Every row update rewrites the whole tuple; TOAST kicks in; indexes bloat.
Foreign key without indexcategory_id — PostgreSQL does not index FK columns automatically. Every DELETE/UPDATE on categories must scan feeds 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 feeds maintains it — insert, update and delete alike — and it takes disk. Weigh that against how often categories rows are actually deleted or re-keyed — if the answer is never, the scan never happens and the index only costs.
Wide table (42 columns) — Tables this wide usually hide several entities (or a JSON column that wants to be one). Every row update rewrites the whole tuple; TOAST kicks in; indexes bloat.
Enum-like column with no CHECKmime_type — mime_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.
Column
Type
Null
Default
References
Notes
idPK
bigserial
NOT NULL
hashUK
text
NOT NULL
mime_type
text
NOT NULL
content
bytea
NOT NULL
external_id
text
null
Indexes
UNIQUE (external_id) WHERE external_id <> ''icons_external_id_idx
Foreign key without indexicon_id — PostgreSQL does not index FK columns automatically. Every DELETE/UPDATE on icons must scan feed_icons 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 feed_icons maintains it — insert, update and delete alike — and it takes disk. Weigh that against how often icons rows are actually deleted or re-keyed — if the answer is never, the scan never happens and the index only costs.
Foreign key without indexentry_id — PostgreSQL does not index FK columns automatically. Every DELETE/UPDATE on entries must scan enclosures 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 enclosures maintains it — insert, update and delete alike — and it takes disk. Weigh that against how often entries rows are actually deleted or re-keyed — if the answer is never, the scan never happens and the index only costs.
Enum-like column with no CHECKmime_type — mime_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.
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 — schema_version 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.
Isolated table — tokens 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.
Isolated table — acme_cache 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.