Skip to content

PostgreSQL JSONB in 2026: When I Reach for It (and When I Don’t)

PostgreSQL JSONB in 2026: When I Reach for It (and When I Don’t)

I built a side-project schema in 2022 where almost every table had a column called data jsonb. I thought I was being clever. I was being lazy. Two years later an analyst asked me to find every order from a specific supplier in the last quarter, and I spent forty-five minutes writing a query that should have taken three. The cause: I’d stuffed supplier_id into JSONB instead of giving it its own column.

If you’re reaching for jsonb in Postgres because it feels flexible, this post is the conversation I wish someone had with me. JSONB is great in the right place and a slow tax everywhere else. Here’s how I decide in 2026.

Short version for the impatient: JSONB is for data whose shape genuinely varies per row. Everything else belongs in normal columns. If you can list the keys you’d put in JSONB on a piece of paper, those keys are columns.

The mistake I made for two years

Early on I treated JSONB as “schema-less Postgres”. That framing is technically true and operationally awful. The trap is this: schema-less storage means schema-less queries, which means every consumer of your data has to know the implicit shape. The implicit shape rots. People add keys. Someone capitalizes one wrong. You end up with the worst of both: a relational database holding documents, with neither relational guarantees nor document-database tooling.

The version of my schema that finally worked looked like this:

-- before: lazy, regretted later
CREATE TABLE orders (
  id    bigserial PRIMARY KEY,
  data  jsonb NOT NULL
);

-- after: real columns for the things I query, jsonb for the rest
CREATE TABLE orders (
  id           bigserial PRIMARY KEY,
  customer_id  bigint NOT NULL REFERENCES customers(id),
  supplier_id  bigint NOT NULL REFERENCES suppliers(id),
  total_cents  integer NOT NULL,
  placed_at    timestamptz NOT NULL,
  metadata     jsonb NOT NULL DEFAULT '{}'::jsonb
);

The split is the whole game. Anything I filter, sort, group, or join on becomes a column. Anything that’s payload (webhook bodies, customer-supplied form data, vendor-specific extras) goes in metadata.

What JSONB is actually good for

Three places I keep reaching for it on purpose.

First, webhook payloads and audit trails. The shape comes from the outside world. I don’t control it. I want to keep the original blob for forensics, plus a few extracted columns I actually query. The full payload sits in JSONB, untouched.

Second, per-tenant custom fields. A B2B app where every tenant adds their own attributes. I keep a custom_fields jsonb and let the app define what’s in there. The Postgres docs cover the JSONB type and operators in detail; the ->, ->>, and @> operators do most of the work.

Third, configuration that genuinely varies. Feature-flag rules, integration settings, theming. Stuff where a separate settings table would mean a wide schema with mostly null columns.

A concrete example of querying it cleanly:

-- find orders where the metadata says shipping is express
SELECT id, customer_id, total_cents
FROM orders
WHERE metadata @> '{"shipping": "express"}'::jsonb
  AND placed_at > now() - interval '7 days';

The @> containment operator is fast when you have the right index. We’ll get there.

Where JSONB quietly hurts

Now the parts that the marketing posts skip.

No type checking inside JSONB. A field is "42" in some rows and 42 in others. Postgres doesn’t care. Your code does. I’ve watched a billing job silently exclude rows because a string "true" slipped in where everything else had true. The Crunchy Data team wrote a sober post called JSONB: the good, the bad, and the ugly that names this exact failure mode and a few others worth reading.

Updates rewrite the whole document. A small change to one key inside a 4 KB JSONB blob writes 4 KB of WAL. If you’re updating a JSONB column on a hot path, that adds up. TOAST helps with storage but updates still go through the full document path.

Column statistics are weak. The planner has rich statistics for normal columns: histograms, most common values, n_distinct. For JSONB it has almost nothing. If you filter on metadata->>'status', the planner is guessing at row counts. Bad guesses become bad plans.

Joins are awkward. You can’t do JOIN suppliers ON suppliers.id = (orders.metadata->>'supplier_id')::bigint and feel good about yourself. The cast prevents the planner from using a hash join cleanly. Foreign keys won’t work at all.

Schema migrations get harder, not easier. The whole pitch of JSONB is “no migrations”. In practice, when the shape stabilizes you still need to backfill, validate, and maybe split keys into columns. Now you have a migration that scans and rewrites every row instead of an ALTER TABLE ADD COLUMN that completes in milliseconds. I’ve sat through three of these. Each one took longer than the relational version would have.

Indexing JSONB without losing your weekend

Most of the JSONB grief I’ve seen comes from missing or wrong indexes. Three patterns cover most needs.

Whole-document containment (@>). Use a GIN index with jsonb_path_ops:

CREATE INDEX idx_orders_metadata_path
  ON orders USING gin (metadata jsonb_path_ops);

jsonb_path_ops is smaller and faster than the default GIN ops, but only supports @>. That’s usually fine. The full reference is in the Postgres JSON functions docs.

Specific key lookups. Use an expression index:

CREATE INDEX idx_orders_metadata_status
  ON orders ((metadata->>'status'));

This supports WHERE metadata->>'status' = 'shipped' and gives the planner real statistics for that expression. It’s how I get most of the wins.

Partial indexes for hot subsets. Combine the above:

CREATE INDEX idx_orders_active_express
  ON orders ((metadata->>'priority'))
  WHERE metadata @> '{"shipping": "express"}'::jsonb;

Partial indexes are underused. If five percent of your rows match a hot filter, a partial index over them is tiny and obscenely fast.

A blunt rule: if you query a JSONB key more than once a week and there’s no index for it, you have a problem. Check pg_stat_user_indexes to see whether the indexes you added are actually being used.

ORMs and JSONB: keep the ergonomics, drop the abstraction

I’ve worked through both Drizzle and Prisma with JSONB, and they both end up wanting raw SQL once the queries get real. JSONB containment, partial indexes, and expression indexes are all features ORMs leak through awkwardly. My rule: model the JSONB column as opaque in the type system, and write the few queries that touch it with raw SQL inside a thin repository function. Don’t let the ORM pretend it understands the document shape.

// drizzle: keep jsonb opaque, query with sql template
const expressOrders = await db.execute(sql`
  SELECT id, customer_id, total_cents
  FROM orders
  WHERE metadata @> ${JSON.stringify({ shipping: 'express' })}::jsonb
    AND placed_at > now() - interval '7 days'
`);

You get predicate pushdown, you get the index, you skip the round trips through the ORM’s JSON helpers. I cover this kind of pattern in my database-heavy backend work.

The decision tree I actually use now

Three questions, in order.

Will I query, sort, or join on this field? If yes, it’s a column. JSONB is not where queryable data lives.

Is the shape controlled by my application and stable? If yes, it’s still a column or a side table, not JSONB.

Does the shape come from outside or genuinely vary per row? If yes, JSONB is the right tool. Add the indexes you need and treat the column as opaque to the rest of the schema.

I keep getting better answers when I’m willing to add a column instead of stuffing one more key into JSONB. Schema migrations are cheap. Slow queries are not.

What you can do this week

Open one of your apps with a JSONB column. Run this:

SELECT
  jsonb_object_keys(metadata) AS key,
  count(*) AS rows
FROM orders
GROUP BY key
ORDER BY rows DESC
LIMIT 20;

Any key that shows up in most rows and that you also query against is a column waiting to be pulled out. Promote it. You’ll feel the difference the next time you write a WHERE clause.

After that, look at your slowest query log for any plan that touches JSONB. If the plan does a sequential scan and filters with metadata->>'something' = '...', add an expression index. If the plan filters with @>, add a jsonb_path_ops GIN index. Most of the time you don’t need both. Run EXPLAIN ANALYZE before and after so you can see the win, not just hope for it. None of this is fancy. It’s the boring middle ground between treating JSONB as a magic schema-less escape hatch and refusing to use it at all, which is where I’ve ended up after a few years of getting both extremes wrong.