Queries and performance

Use JSONB deliberately

Keep flexible nested data in JSONB without turning every relational value into an opaque document.

JSONB stores parsed JSON and supports operators and indexes for querying inside it. I reach for it when the shape of the data genuinely varies between rows, not when I am avoiding normal columns.

It works well for optional metadata whose keys change from event to event:

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';

That should return the row you just inserted. -> returns JSON. ->> returns text. This distinction affects comparisons and indexes. PostgreSQL also supports containment queries such as metadata @> '{"plan":"pro"}', which match when the document includes those keys and values.

A general GIN index can speed up 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. 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 this on your own project: 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