Get CV

Database

PostgreSQL JSONB is not a listing index

A GIN on payload looks complete. WHERE title->>'ru' still seq-scans. Containment and extract are different jobs.

The catalog listing on a shop that had moved to PostgreSQL still seq-scanned. On shops that already used MySQL JSON I sometimes moved the catalog to PostgreSQL for JSONB and GIN. The listing still filtered published rows and sorted by an editor field. The WHERE clause used title->>'ru'. EXPLAIN showed Seq Scan. The GIN sat unused.

Editors waited on the public grid. PostgreSQL burned CPU on extract. Vue SSR printed forty tiles and paid for a full table read. GIN on JSONB is built for containment: payload @> '{"tag":"sale"}'. The ->> operator produces text. That text is not what the GIN stores unless you add an expression index on the extract.

I keep JSONB for the admin document. The public list does not read it.

GIN matches containment, not every extract. A stored column can use a btree.
GIN matches containment, not every extract. A stored column can use a btree.

Generated text, btree for the grid

title_ru is a stored generated column from the JSONB. The listing SELECT is id, slug, price, title_ru. The index is (is_published, sort_order, id) and includes those fields. Sort stays on a small integer the editor sets. Title sort belongs in admin, not on the public grid.

Search that really needs JSON keeps a GIN and uses @> or jsonb_path_exists. Mixing both in one query is how you get a seq scan and a gin scan that still sorts in work_mem.

I tried an expression index on (title->>'ru'). It helped a filter on one locale. The listing still sorted sort_order and still needed published. Two indexes, two plans, and a third locale meant two more expression indexes. Generated columns plus one btree were smaller to reason about. Writes got a bit heavier. The grid got a plan I could read.

Listing reads typed columns. Search may use GIN. One query should not do both badly.
Listing reads typed columns. Search may use GIN. One query should not do both badly.

What I do not store in JSONB

Price, published flag, and sort order stay typed columns. Vue SSR receives one locale string. PHP does not json_decode three languages to print forty tiles. MySQL and PostgreSQL then look the same from the listing: covering btree, compact rows, JSON off the hot path.

The stack still has MySQL on this site. PostgreSQL is the other catalog I run. The rule does not change with the engine. JSON is the form. The list is a table.

I dumped pg_stat_user_indexes after the GIN went in. idx_scan on the GIN stayed near zero for the listing route. idx_scan on the btree moved. That is the measurement that ended the argument that JSONB was already indexed.

What I took from this

I read EXPLAIN and pg_stat_user_indexes on the listing route, not the presence of a GIN. Unused GIN plus Seq Scan is the usual shape.

I do not trust title->>'ru' to use a payload GIN. Containment is @>. Extract needs a generated column or an expression index.

The rule I keep: typed columns for price, published, sort. One locale string to Vue. JSONB stays the admin document. Do not mix listing sort and JSON search in one query.

Back to notes