Skip to content
EL
Contents

JSON and semi-structured data in PostgreSQL

Relational columns or a JSON document?

DecisionCore4 min read15 min hands-on#sql#data-modeling

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

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

  1. Why can’t the database protect tenant_id when it lives inside the document?

    Show answer

    Foreign keys and NOT NULL apply 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.

  2. 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.

  3. Why did the first CHECK constraint accept {"enable": true}?

    Show answer

    The enabled key was missing, so config -> 'enabled' was NULL and the check evaluated to NULL. CHECK constraints 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. 1.
    PostgreSQL documentation: 66.2 TOAST
    Primary· §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. 2.
    PostgreSQL documentation: 5.5 Constraints
    Primary· §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. 3.
    PostgreSQL documentation: 8.14 JSON Types
    Primary· §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. 1.
    PostgreSQL documentation: 8.14 JSON Types
    Primary· §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.