Skip to main content

6 min read #postgresql #jsonb #mongodb #database #backend

I reached for MongoDB out of habit. Postgres JSONB was the answer.

I wanted schemaless flexibility for a feature, so my hand went to MongoDB. Then I remembered Postgres has had JSONB for a decade — and I got the flexibility without giving up joins, transactions, or one database.

A while back I was building a feature where the shape of the data was genuinely uncertain — user-defined fields, third-party payloads I didn't fully control, the kind of thing where a rigid table felt wrong. My instinct, trained by years of conference talks, fired instantly: this is a document store, reach for MongoDB. So I started sketching a second database into the architecture.

Then I stopped, because I'd already had a perfectly good Postgres instance sitting right there, and Postgres has had JSONB since 2014. The question wasn't "do I need a document database." It was "do I need a second database." The answer was no, and I think that's the answer more often than the industry admits.

JSONB is a real column type, not an escape hatch

The trick people miss is that JSONB isn't a blob you dump and forget. It's binary-encoded, queryable, and indexable. You can have a table that is mostly structured — proper typed columns, foreign keys, the works — with one JSONB column for the part that's legitimately freeform:

CREATE TABLE event (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id     bigint NOT NULL REFERENCES app_user(id),
  created_at  timestamptz NOT NULL DEFAULT now(),
  kind        text NOT NULL,
  payload     jsonb NOT NULL          -- the schemaless part
);

Now user_id and created_at are relational and join cleanly, while payload holds whatever each event kind needs. I didn't have to choose between "rigid SQL" and "flexible documents." I got both, in one row.

And it's actually fast — containment + GIN

The part that retires the "but Mongo queries documents and Postgres can't" argument is the @> containment operator backed by a GIN index. Want every event whose payload contains a given key/value? You index the column once:

CREATE INDEX idx_event_payload ON event USING gin (payload);

-- "find events whose payload contains this fragment"
SELECT id, created_at
FROM event
WHERE payload @> '{"status": "failed", "retryable": true}';

That query uses the index. It's not a full scan parsing JSON row by row — the GIN index understands containment directly. For the read patterns I actually had, it was every bit as quick as the document store I'd been about to bolt on, and I never left the database my other data lived in.

The thing you keep that documents make you give up

Here's what tipped it for me: transactions. The feature wasn't only the freeform payload — writing an event also touched a couple of relational tables, updated a counter, the usual. With one Postgres I wrap all of it in a single transaction and it's atomic: the structured write and the document write either both happen or neither does.

With a separate document database, that same operation becomes a distributed-write problem — two systems, two failure modes, and the eternal question of what happens when the first write succeeds and the second doesn't. I'd have spent more effort reconciling two databases than I ever spent missing MongoDB's ergonomics. ACID across the structured and unstructured parts of the same row is a quiet superpower.

Where I'd still hesitate — the honest limits

This isn't "MongoDB is bad," it's "reach for it on purpose." JSONB has real edges:

  • Deep partial updates are clumsy. Updating one field deep inside a big document with jsonb_set works, but it rewrites the whole value, and the syntax is nobody's favorite. If your workload is constant tiny mutations of large nested documents, a document database's update operators are genuinely nicer.
  • Documents are still rows. A multi-megabyte JSONB value per row will push Postgres into TOAST storage and your performance assumptions change. JSONB is for "a flexible part of a record," not "I'm storing 5 MB blobs and querying into them constantly."
  • No schema means no schema. The flexibility cuts both ways — Postgres won't stop you writing inconsistent shapes into the column. If half your value is in the structure, model it as columns and let the database enforce it.

The takeaway: one database you understand

The real win wasn't a benchmark. It was operational. Every database you run is a thing to back up, monitor, secure, upgrade, and reason about when it's 2 a.m. and something's slow. Folding the "schemaless" feature into the Postgres I already operated meant zero new operational surface — same backups, same connection pool, same mental model, same transactions. Adding MongoDB would have doubled a lot of that to solve a problem Postgres had already solved a decade ago. Reach for the second database when the first one genuinely can't do the job. Increasingly, with Postgres, it can.