JSON and semi-structured data in PostgreSQL
Relational columns or a JSON document?
The idea in 30 seconds
JSON columns are right for data whose shape genuinely varies and that you mostly read as a whole. Identity, ownership, status and anything you filter, join or update often belong in real columns, where the database can enforce types, keys and NOT NULL, index them simply, and update them without rewriting the whole document.
Helps to know first: SQL types and the type system, B-tree indexes
On this page
The situation
The product is adding integrations: each customer organization can connect Slack, Jira or a webhook, each with its own settings. An AI assistant proposed the table.
Review taskStore integrations as documents
Pull request: “Integrations have different settings per provider, so store each one as a single JSON document. Flexible, and no migrations when providers add fields.”
CREATE TABLE integrations (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
doc jsonb NOT NULL
);
-- example row
-- {"tenant_id": 7, "provider": "slack", "status": "active", "enabled": true,
-- "channel": "#alerts", "mappings": {...60 field mappings...}}
-- the integrations page
SELECT * FROM integrations WHERE doc ->> 'tenant_id' = '7';Would you approve it? Write the review you would leave.
Your call
Model review
Request changes. The provider-specific settings are a good fit for JSON. The tenant, provider and status are not.
1. The tenant is no longer protected. tenant_id inside a document cannot have a foreign key, cannot be NOT NULL, and is compared as text. A document with no tenant_id, or "tenant_id": "7 ", or a typo in the key, is accepted, and the integration silently belongs to nobody, or never shows up on the tenant’s page.
2. Every filter needs its own index. The integrations page filters on doc ->> 'tenant_id', the scheduler on doc ->> 'status'. Each needs a separate expression index written exactly that way. On our million-row test table, a GIN index on the whole document did nothing for a ->> comparison: 37 ms with or without it.
3. Changing one setting rewrites the whole document. We measured it on 100,000 integrations with about 3 KB of settings each:
| Update all rows | Time | WAL written |
|---|---|---|
status as a column | 0.5 s | 32 MB |
enabled as a key inside the document | 10.3 s | 652 MB |
PostgreSQL keeps unchanged values as they are when it updates a row, so changing a small column leaves the large document untouched. Changing one key inside the document is a new document: all of it is written again, along with its log records.
4. Types drift. Some writers will send "enabled": true, others "enabled": "true". Both are valid JSON; only one is a boolean.
What I would ask for instead:
CREATE TABLE integrations (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id integer NOT NULL REFERENCES tenants (id),
provider text NOT NULL,
status text NOT NULL CHECK (status IN ('active', 'paused', 'failed')),
enabled boolean NOT NULL DEFAULT true,
settings jsonb NOT NULL DEFAULT '{}' -- provider-specific, read as a whole
);
CREATE INDEX ON integrations (tenant_id);Columns for what you filter, join, enforce or update often; a document for the part that truly varies by provider.
Compare it with yours. Did your review catch the main problems?
Guarding the document that remains
When a few keys inside a document must be right, a CHECK constraint can enforce them. Be careful with missing keys:
CREATE TEMP TABLE integ (id int PRIMARY KEY, config jsonb NOT NULL,
CHECK (jsonb_typeof(config -> 'enabled') = 'boolean'));
INSERT INTO integ VALUES (1, '{"enabled": true}'); -- accepted
INSERT INTO integ VALUES (2, '{"enabled": "true"}'); -- rejected: a string
INSERT INTO integ VALUES (3, '{"enable": true}'); -- accepted!
Row 3 has the key misspelled. config -> 'enabled' is then NULL, so the check is NULL rather than false, and a CHECK constraint lets NULL through. Require the key as well:
CHECK (config ? 'enabled' AND jsonb_typeof(config -> 'enabled') = 'boolean')
With that, both bad rows are rejected.
How to decide
Prefer a column for anything that identifies or owns the row, anything you filter, join or sort on regularly, anything with a fixed type, and anything updated on its own.
Prefer a JSON document for attributes whose set of keys varies from row to row, that you mostly read and write as a whole, and that you rarely filter on.
Promote keys that become important. When a key inside a document starts appearing in WHERE clauses everywhere, move it into a column.
Check yourself
Why can’t the database protect
tenant_idwhen it lives inside the document?Show answer
Foreign keys and
NOT NULLapply to columns, not to keys inside a JSON value. The document can omit the key, misspell it or give it the wrong type, and nothing rejects it.Why did updating one key inside the document write 20 times more WAL than updating a column?
Show answer
A changed JSON value is a whole new value, so the entire document is written again. When only a small column changes, PostgreSQL keeps the stored document as it was.
Why did the first
CHECKconstraint accept{"enable": true}?Show answer
The
enabledkey was missing, soconfig -> 'enabled'was NULL and the check evaluated to NULL.CHECKconstraints only reject rows where the expression is false.
Finished reading?
Saved in this browser only.
Sources
Where each claim on this page comes from.
- 1.PostgreSQL documentation: 66.2 TOASTPrimary· §66.2 TOAST
Values wider than about 2 kB are compressed or moved out of line; an UPDATE preserves unchanged fields' stored values as-is, so it incurs no TOAST cost unless those values change.
- 2.PostgreSQL documentation: 5.5 ConstraintsPrimary· §5.5.1 Check Constraints, §5.5.5 Foreign Keys
A check constraint is satisfied when its expression is true or null; foreign keys can only reference columns.
- 3.PostgreSQL documentation: 8.14 JSON TypesPrimary· §8.14.4 jsonb Indexing
GIN indexes on jsonb serve containment and existence operators.
Go further
What to read next, and why it is worth the time.
- 1.PostgreSQL documentation: 8.14 JSON TypesPrimary· §8.14.2 Designing JSON Documents
The documentation's own advice that even flexible documents should keep a predictable structure, and that JSON and relational columns can complement each other.
Applies to PostgreSQL 18. Draft: not yet fact-checked end to end.