Topic 09

JSONB, and When Not to Use It

Documents

jsonb stores a parsed, binary JSON document that Postgres can index, query with containment operators and constrain like any other value. It is the feature that stops teams from running a document database alongside their relational one. It is also the fastest way to move an unversioned schema past a code review, because a column that accepts any shape never rejects anything.

Cartwheel currently has neither benefit. products.attributes is a text column holding JSON that the Python service parses on every read, so as far as the database is concerned it is a string: no key can be indexed, no key can be required, and the planner's statistics describe string lengths. Fixing that means a table rewrite — affordable on a catalogue of tens of thousands of products, and the same statement against orders would be the outage the numbers topic warned about.

json vs jsonb

json keeps an exact copy of the input text and reparses it on every access, preserving insignificant whitespace, the order of object keys, and duplicate keys. jsonb parses once at write time into a decomposed binary form, discards whitespace and key order, keeps only the last value for a duplicate key, and is the only one of the two that supports indexing.

The same input, two types, two results
SELECT '{"b":1,   "a":2, "a":3}'::json;
 {"b":1,   "a":2, "a":3}

SELECT '{"b":1,   "a":2, "a":3}'::jsonb;
 {"a": 3, "b": 1}

The jsonb result answered the duplicate-key question — last value wins — and normalized the layout, and it did the parsing once instead of on every read for the next five years. The documentation's recommendation is that most applications should prefer jsonb unless they have a specialized need such as a legacy assumption about key ordering. Picking json because it came first in the autocomplete list gives up indexing entirely.

Promoting the column, on a table small enough to afford it
ALTER TABLE products
  ALTER COLUMN attributes TYPE jsonb USING attributes::jsonb;

That statement parses every existing document, rejects any row whose text was never valid JSON, and rewrites the table under an ACCESS EXCLUSIVE lock. On products it runs in seconds, because the table holds tens of thousands of rows rather than tens of millions.

Reading Into a Document

Four extraction operators and a path language cover almost everything. -> returns a jsonb value, ->> returns text, and #> and #>> do the same for a path rather than a single key. Containment, written @>, asks whether one document contains another. The SQL/JSON path language expresses the questions the arrows cannot.

Extraction, containment, and a path predicate
SELECT attributes->'brand'              FROM products WHERE id = 4471;   -- "Braeburn Farms"
SELECT attributes->>'brand'             FROM products WHERE id = 4471;   -- Braeburn Farms
SELECT attributes#>>'{nutrition,kcal}'  FROM products WHERE id = 4471;   -- 52

SELECT count(*) FROM products WHERE attributes @> '{"organic": true}';
SELECT count(*) FROM products WHERE attributes @? '$.pack[*] ? (@.grams > 500)';

The first two lines differ by one character and by their type: -> hands back a JSON string complete with its quotes, so comparing it against a text literal either raises an operator error or never matches at all. Compare with ->>. Containment ignores the order of array elements and treats duplicates as one, which makes it the right operator for "is this product tagged organic" and the wrong one for anything positional. The path form is the only one of the five that can express "any element of this array where grams exceeds 500".

Indexing It

A document is indexable in three different ways and the choice follows the access pattern rather than the data.

Three indexes, three different jobs
-- every key and every value, independently: containment and key existence
CREATE INDEX products_attributes_gin ON products USING gin (attributes);

-- smaller and sharper, containment only
CREATE INDEX products_attributes_gin ON products USING gin (attributes jsonb_path_ops);

-- one key that is queried constantly
CREATE INDEX products_brand_idx ON products ((attributes->>'brand'));

The default GIN operator class indexes each key and each value as separate entries, which answers key-existence and containment across the whole document at the cost of a large index. jsonb_path_ops stores a hash of each value together with the path that leads to it: smaller than the default class, more selective, faster for containment, and blind to structures that hold no value at all. When a single key dominates the workload, a plain B-tree on the extracted expression beats both, because it is a fraction of the size and it supports ranges and ordering that GIN cannot. Under jsonb_path_ops, a search for {"a": {}} falls back to scanning the whole index.

Three indexes over one document, three different jobs
gin (attributes)the default operator class
Indexes every key and every value as separate entries, so it answers key existence and containment across the whole document — at the cost of a large index.
gin (attributes jsonb_path_ops)containment only
Stores a hash of each value with the path that leads to it: smaller, more selective, faster for containment, and blind to a structure that holds no value at all.
btree on one extracted key(attributes->>'brand')
When a single key dominates the workload: a fraction of the size, and it supports the ranges and ordering GIN cannot.

What It Costs

The whole document is one column value, and that is where the bill starts. Postgres invokes TOAST once a row exceeds roughly 2 KB: the value is compressed and, if that is not enough on its own, moved out of line into a side relation, which is the mechanism Chapter 5 opens up. Every query that reads or filters on attributes then pays to fetch and decompress the document back, including a GIN-indexed containment query, which has to recheck the candidate rows against the real thing.

Updates are worse, because Postgres has no partial update of a value. Changing one key in a 4 KB document writes a complete new version of the row and a complete new TOAST chain, and the first modification of each of those pages after a checkpoint puts a full 8 KB page image into the write-ahead log. A counter bumped inside a 4 KB document costs hundreds of kilobytes of WAL.

The third cost is the one that bites hardest and shows up last. The planner's statistics on a jsonb column are coarse, so the row estimate for a @> predicate is frequently a guess — Chapter 9 catches one of those guesses on Cartwheel in EXPLAIN. GIN still returns the right rows. The wrong estimate propagates upward, flips a join order, and turns a nested loop into a plan that reads far more than it needed to.

Where the Boundary Is

Cartwheel keeps attributes as a document because a bottle of olive oil and a bag of ice genuinely do not share a field list: one has an origin, a pressing date and an acidity, the other has a cube size and a bag weight. Inventing forty mostly-null columns to hold that, or a key-value side table with a join for every attribute, would both be worse. What does not go in the document is price, sku or the stock level, because those are filtered, sorted, constrained and joined.

Anything with a business rule attached belongs in a column, which is short enough to apply in review. Store the whole application object as one blob and query it with @> for everything, and you get no constraints, no usable statistics, no cheap partial update, and a products table that decompresses a 4 KB document to answer a question about a price.

Column or document — the question to ask in review
The field is filtered, sorted, constrained or joined — price, sku, the stock levelA real column
The shape genuinely varies per row and no business rule reaches into it — an acidity, a cube sizeA key in jsonb
The whole application object as one blob, queried with containment for everythingThe failure to avoid

Constraining a Document

Some of the guarantees the document threw away can be bought back without giving up the flexibility that justified it.

Shape rules on a document, and one key promoted to a real column
ALTER TABLE products
  ADD CONSTRAINT products_attributes_object
      CHECK (jsonb_typeof(attributes) = 'object'),
  ADD CONSTRAINT products_attributes_unit
      CHECK (attributes ? 'unit');

ALTER TABLE products
  ADD COLUMN brand text
      GENERATED ALWAYS AS (attributes->>'brand') STORED;

The first constraint refuses an array or a bare string arriving where the application expects an object. The second turns a missing required key into a write that fails immediately rather than a NULL-shaped bug found three releases later. The generated column leaves the document as the source of truth while handing the planner a typed, indexable value, and it has to say STORED explicitly: since 18 generated columns are virtual by default, and a virtual one cannot be indexed.

jsonb vs proper columns vs a document database

Proper columns — types, constraints, statistics the planner can use, and updates that touch only what changed. Use them for every field the application actually depends on, which is more of them than the first design assumes.

A jsonb column — sparse, per-row shapes inside the same transaction and the same backup as the rest of the schema. Use it where the shape genuinely varies per row and no business rule reaches into it.

A separate document database — a second system to operate, back up, monitor and keep consistent with this one. The case for it has to be stronger than "our shape varies", because jsonb with a GIN index already covers that ground transactionally.

Common Mistakes
  • Keeping JSON in a text column so the application can parse it — the database cannot index a key, require a key or estimate anything, and the parse cost is paid on every single read.
  • Storing the entire application object as one document and querying it with @> for everything — no constraints, no useful statistics, and a read of the price decompresses kilobytes of unrelated attributes.
  • Writing -> where ->> was meant — comparing a JSON value against a text literal either raises an operator error or matches nothing at all, depending on which side is cast.
  • Updating one key of a large document at high frequency — each update writes a whole new row version plus a new TOAST chain, and the full-page images turn a small change into hundreds of kilobytes of WAL.
  • Adding a GIN index over documents with thousands of distinct keys and expecting good row estimates — the index answers the predicate, the estimate stays a guess, and the bad guess flips the join order above it.
  • Choosing json instead of jsonb for a new column — it forfeits every index type and reparses the text on each access, for a key-ordering guarantee almost no application needs.
Best Practices
  • Promote any key the application filters, sorts or constrains on into a real column, and use a STORED generated column when the document should remain the source of truth.
  • Index with jsonb_path_ops when containment is the only access pattern, and with an expression B-tree when a single key carries most of the queries.
  • Keep documents well under the 2 KB TOAST threshold where you can, and split rarely-read parts out of a document that is routinely several kilobytes.
  • Add CHECK constraints for the keys the application genuinely requires, so a malformed write fails at the insert rather than surfacing as a missing value later.
  • Use ->> whenever the comparison is against text, and reserve -> for the cases where the next operation is itself a JSON operation.
  • Check the estimated against the actual row counts in EXPLAIN ANALYZE for every @> predicate on a hot path, because that is where a document column misleads the planner with no other symptom to show for it.
Comparable toolsMongoDB document-native, a separate system to runMySQL binary JSON, expression indexes onlySQL Server JSON functions and OPENJSONOracle JSON with its own path languagehstore the flat key-value ancestor in Postgres

Knowledge Check

What does choosing json instead of jsonb for a new Postgres column actually cost you?

  • The ability to store nested objects and arrays beyond one level deep
  • All indexing, plus a reparse of the stored text on every single access
  • Out-of-line storage, since json values must fit entirely inside the row
  • Transactional durability, because json writes bypass the write-ahead log

A query compares attributes->'brand' with the text literal 'Braeburn Farms' and never matches any row. What is wrong?

  • The single arrow returns a JSON value, not the text the comparison expects
  • Key lookups in jsonb are case-insensitive, so the wrong key is being read
  • jsonb strips string values during normalization when a document is stored
  • The column needs a GIN index before extraction operators return any rows

Cartwheel updates one key inside a 4 KB attributes document a few thousand times an hour. What does each update write?

  • Only the changed key, since jsonb stores each key as a separately addressable item
  • A whole new row version and TOAST chain, plus full-page images into the WAL
  • A patch applied in place to the existing document on its original page
  • Only the GIN index entries, because the document itself is stored out of line

Which of Cartwheel's product fields belongs in the attributes document rather than in a column of its own?

  • price, since it is a simple scalar that never needs its own constraint
  • sku, because it is short text and reads well beside the other attributes
  • acidity, which exists for olive oil and for very few other products
  • the stock level, because it changes far more often than the rest of the row

When is jsonb_path_ops the better GIN operator class over the default?

  • When queries mostly ask whether a given key exists anywhere in the document
  • When containment is the only access pattern and the index should stay small
  • When the documents need to be sorted or filtered by a range of values
  • When many documents contain empty objects that still need to be searchable

You got correct