Queries and performance
Use JSONB deliberately
Keep flexible nested data in JSONB without turning every relational value into an opaque document.
8 minute lesson
jsonb stores parsed JSON and supports operators and indexes for querying inside it.
It works well for optional metadata whose keys genuinely vary:
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
kind text NOT NULL,
occurred_at timestamptz NOT NULL,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb
);
INSERT INTO events (kind, occurred_at, metadata)
VALUES ('purchase', now(), '{"plan":"pro","source":"newsletter"}');
Query a text value with ->>:
SELECT id
FROM events
WHERE metadata ->> 'source' = 'newsletter';
-> returns JSON; ->> returns text. This distinction affects comparisons and indexes. PostgreSQL also supports containment queries such as metadata @> '{"plan":"pro"}'.
A general GIN index can accelerate many containment and key-existence queries:
CREATE INDEX events_metadata_gin_idx
ON events USING gin (metadata);
For one frequent expression, a smaller expression index may be a better fit:
CREATE INDEX events_source_idx
ON events ((metadata ->> 'source'));
Use EXPLAIN ANALYZE with representative data before deciding. The broad GIN index costs more storage and write work, while an expression index serves a narrower query.
Keep ordinary columns for values you routinely filter, join, constrain, sort, or update independently. Hiding customer_id, status, or timestamps inside JSON weakens type checks and foreign keys and makes every query repeat extraction logic. JSONB fits the flexible edge of a relational model, not the entire model by default.
Updates rewrite the JSONB value, so a large document that changes one small key can create unnecessary write amplification. Split frequently updated fields into columns or related tables.
Try it: store two event kinds with different metadata, query them by containment, inspect the plan before and after an index, then decide whether the measured workload justifies keeping it.
Lesson completed