You have data whose shape varies — user preferences, event payloads, third-party API responses, product attributes that differ by category. Modelling every possible field as a column means a wide sparse table and a migration whenever something new appears.
A JSON column holds the whole document. It solves that problem and hands back several others.
What changed in 2026
- Binary JSON became the default. Text-based JSON storage largely disappeared from new designs.
- Generated columns became the standard companion. Extracting queried fields into real indexable columns emerged as best practice — see generated columns.
- Indexing improved. Better support for indexing inside documents narrowed the gap with real columns.
- The hybrid model settled. Structured columns for known fields, JSON for the variable remainder, became the standard shape rather than choosing one.
Binary, not text
The first decision, and it is not close.
Text JSON stores the document as a string. Every read parses it, whitespace and key order are preserved pointlessly, and indexing inside it is not possible.
Binary JSON parses once on write and stores a structured representation. Reads extract fields without parsing the whole document, and the format supports indexing inside it.
The only reason to store text is if you need byte-exact preservation of the original document — an audit requirement, or a signature over the exact bytes. Otherwise binary, always.
What you give up
|
Real columns |
JSON column |
| Type enforcement |
Yes |
No |
| NOT NULL, CHECK constraints |
Yes |
Awkward at best |
| Foreign keys |
Yes |
No |
| Indexing |
Straightforward |
Possible, more work |
| Query planner statistics |
Good |
Weaker |
| Schema visible in the database |
Yes |
No |
| Adding a field |
Migration |
Nothing |
The constraint row is the one that causes real problems over time. Nothing stops a string appearing where every other row has a number, a required field being absent, or a typo in a key name creating a parallel field nobody notices. The database will not tell you; a report will, months later.
The statistics row matters for performance. The planner has good information about column distributions and much weaker information about what is inside a document, which produces worse row estimates and therefore worse plans — see query planners.
The hybrid that works
The design most teams converge on: put the fields you query, filter, or join on in real columns, and the variable remainder in JSON.
Generated columns make this clean. Define a stored generated column extracting a field from the document, and you get a real, typed, indexable column that stays automatically in sync with the JSON. No application code maintains it, no drift is possible.
That gives you indexed queries on the fields that matter, type checking where you extracted, and schema flexibility for everything else — see generated columns.
Where you must query inside the document directly, index it. Engines offer index types designed for containment and existence queries within documents, which is far better than scanning — see GIN indexes.
The out-of-line storage surprise
Large values are typically stored outside the main table row, in separate storage, and fetched when accessed. That is transparent and it changes performance in ways people do not anticipate.
A table whose rows contain large JSON documents may perform well on queries that do not touch the JSON, because those documents are not in the main table pages being scanned. Then a query selecting the document becomes dramatically slower, because each row now requires an additional fetch.
This is why SELECT * on a table with large JSON columns can be far slower than selecting the columns you need — the difference is not marginal — see TOAST storage.
Common mistakes
- Text instead of binary storage. Parsing on every read, no indexing.
- JSON for stable known shapes. Gives up everything for no benefit.
- No generated columns for queried fields. Slow, unindexed filtering.
- Assuming the shape is consistent. Nothing enforces it.
SELECT * with large documents. Fetches out-of-line data unnecessarily.
- Deep nesting. Awkward to query and index.
- No validation on write. The application becomes the only schema, and it drifts.
FAQ
Should I validate JSON on write?
Yes, in the application or with a check constraint. Without validation the column accumulates variations nobody knows about, and discovering them later is expensive.
How do I index inside a document?
Either extract the field to a generated column and index that, which is best for specific fields, or use a document-aware index type for containment queries across many keys.
Is a document database better?
If nearly everything is documents and you rarely need joins or constraints, possibly. If most of your data is relational with some variable parts, a JSON column keeps one system rather than two.
What about querying arrays inside documents?
Supported and less efficient than a proper join table. If you frequently query by array membership, a separate table is usually the better model.
Where to go next
For extracting fields into indexable columns, read generated columns. For indexing inside documents, GIN indexes, and for the out-of-line storage behaviour, TOAST storage.