jsonb is a tool for schema flexibility, not a default for everything — the trade is query power and integrity for the ability to store shapes you can't predict. The right answer is a decision rule, not "JSONB is modern so use it."
Use real columns when the shape is known
If a field is queried, filtered, joined, sorted, or constrained, it belongs in a typed column. Columns give you:
- type checking and
NOT NULL/CHECKconstraints - foreign keys
- straightforward, cheap B-tree indexes and
ORDER BY - accurate planner statistics — the planner estimates
jsonbpredicates poorly
-- good: these are core, queryable attributes
CREATE TABLE users (
id bigint PRIMARY KEY,
email text NOT NULL UNIQUE,
status text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
Reach for jsonb when the shape varies or is open-ended
jsonb shines when the data is genuinely heterogeneous or sparse: per-tenant custom fields, third-party API payloads with unstable schemas, feature flags, event metadata, or attributes that differ per row and that you mostly read as a blob.
ALTER TABLE users ADD COLUMN preferences jsonb NOT NULL DEFAULT '{}';
-- arbitrary, tenant-defined keys you don't want a migration for each time
A common hybrid is best: typed columns for the stable, queried fields, plus a jsonb column for the long tail of optional/custom attributes.
Use a separate table when there's real structure
If the "JSON" is actually a list of related entities with their own identity, relationships, or that you query independently — order line items, addresses, tags — model it as rows in a child table. You get foreign keys, per-row constraints, indexing, aggregation, and joins that jsonb arrays make painful.
CREATE TABLE order_items (
order_id bigint REFERENCES orders(id),
product_id bigint REFERENCES products(id),
qty int NOT NULL CHECK (qty > 0)
);
The decision rule
- Known, queried, constrained → column.
- Variable / sparse / unknown shape, read as a unit →
jsonb. - A collection of related rows you query or join → separate table.