Engineer JSONB search indexes around actual operators using jsonb_ops, jsonb_path_ops, expression GIN, jsonpath, and plan/selectivity evidence instead of indexing every document blindly.

GIN Indexing for JSONB, Containment Queries, jsonpath, and Selectivity Tradeoffs

Engineer JSONB search indexes around actual operators using jsonb_ops, jsonb_path_ops, expression GIN, jsonpath, and plan/selectivity evidence instead of indexing every document blindly.

Intermediate → Advanced180–240 minutesAdvanced PostgreSQL data modelingCurrent patched PostgreSQL 18.xCore PostgreSQL; Lesson 5 uses trusted supplied btree_gist (database CREATE privilege or admin-preinstalled)ServiceHub disposable schema: app.ch17_*Owner-equivalent lab role with CREATE in schema appLocal/free tooling; psql recommendedLast reviewed: August 2026

Learning outcomes

ServiceHub now has thousands of JSONB work-order profiles. The search workload includes structural containment, top-level key existence, tag membership, and jsonpath predicates. A GIN index is useful only when its operator class matches those operators. “GIN on JSONB” is not one universal index contract.

01

Compare jsonb_ops and jsonb_path_ops by supported operators and physical index size.

02

Use @>, ?, @?, and @@ with EXPLAIN evidence.

03

Demonstrate a query that jsonb_path_ops cannot support.

04

Build an expression GIN index for a frequently searched nested subdocument.

05

Treat selectivity estimates and write/index maintenance cost as part of index design.

1. Build representative JSONB documents

sql · ServiceHub JSONB search dataset
DROP TABLE IF EXISTS app.ch17_profile;CREATE TABLE app.ch17_profile (  work_order_id bigint PRIMARY KEY,  document jsonb NOT NULL);INSERT INTO app.ch17_profileSELECT 1700000 + g,       jsonb_build_object(         'region', (ARRAY['north','south','east','west'])[(g % 4)+1],         'status', (ARRAY['queued','assigned','completed'])[(g % 3)+1],         'priority', (g % 5)+1,         'tags', CASE                   WHEN g % 10 = 0 THEN jsonb_build_array('urgent','pump')                   WHEN g % 7 = 0 THEN jsonb_build_array('electrical','inspection')                   ELSE jsonb_build_array('routine')                 END,         'metrics', jsonb_build_object(           'duration', (g * 13) % 240,           'attempts', (g % 4)+1         )       )FROM generate_series(1,20000) AS g;ANALYZE app.ch17_profile;

The dataset is deterministic, but planner choices remain machine/statistics dependent. We use the same data for before/after plans and avoid claiming a universal latency multiplier.

2. Default jsonb_ops: broad operator coverage

sql · default GIN operator class
CREATE INDEX ch17_profile_document_ops_ginON app.ch17_profile USING GIN (document);SELECT pg_size_pretty(pg_relation_size('app.ch17_profile_document_ops_gin'))       AS jsonb_ops_size;EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, SUMMARY ON)SELECT count(*)FROM app.ch17_profileWHERE document @> '{"region":"north","status":"queued"}'::jsonb;EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, SUMMARY ON)SELECT count(*)FROM app.ch17_profileWHERE document ? 'priority';

The default jsonb_ops class supports containment @>, top-level existence ?/?|/?&, and jsonpath @?/@@. A small table can still make a sequential scan cheaper; index capability and planner choice are separate questions.

3. jsonb_path_ops: narrower operators, different key representation

sql · path-ops index and size comparison
CREATE INDEX ch17_profile_document_path_ginON app.ch17_profile USING GIN (document jsonb_path_ops);SELECT  pg_size_pretty(pg_relation_size('app.ch17_profile_document_ops_gin'))  AS ops_size,  pg_size_pretty(pg_relation_size('app.ch17_profile_document_path_gin')) AS path_ops_size;

jsonb_path_ops supports @>, @?, and @@, but not key-existence operators. It creates a hash-like index key from each value plus its key path, which often makes containment searches more specific and the index smaller. It also produces no index entry for value-less structures such as {"a":{}}, making some containment searches poor fits.

4. Prove an unsupported operator instead of assuming

sql · temporarily remove jsonb_ops and test key existence
BEGIN;DROP INDEX app.ch17_profile_document_ops_gin;EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, SUMMARY ON)SELECT count(*)FROM app.ch17_profileWHERE document ? 'priority';ROLLBACK;

Within that transaction the only whole-document GIN is jsonb_path_ops, which cannot service ?. The planner must find another path—typically a sequential scan. Rolling back restores the default GIN index. This is stronger evidence than looking at one plan while both indexes compete.

5. jsonpath can be indexable—but only when the path yields indexable clauses

sql · jsonpath equality predicate
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, SUMMARY ON)SELECT work_order_idFROM app.ch17_profileWHERE document @@ '$.region == "north" && $.status == "queued"';

For @?/@@, PostgreSQL extracts equality clauses such as an accessor chain equal to a constant and searches GIN keys for those clauses. Not every jsonpath expression becomes an efficient index lookup. Arbitrary arithmetic/range logic may still require rechecks or a scan.

sql · range-style jsonpath: inspect rather than assume index benefit
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, SUMMARY ON)SELECT count(*)FROM app.ch17_profileWHERE document @? '$.metrics.duration ? (@ > 180)';

If duration thresholds are a dominant query dimension, a typed generated/ordinary column plus B-tree statistics may be a better model than asking GIN/jsonpath to behave like a numeric range index.

6. Expression GIN can target a frequently searched subdocument

sql · index only the tags array
CREATE INDEX ch17_profile_tags_ginON app.ch17_profile USING GIN ((document -> 'tags'));EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, SUMMARY ON)SELECT work_order_idFROM app.ch17_profileWHERE (document -> 'tags') ? 'urgent';

The expression in the query must match the indexed expression closely enough for the planner to use it. This targeted index can be smaller and cheaper to maintain than indexing every key/value in a large document when the workload only searches tags.

7. Operator semantics before index syntax

sql · containment is structural, not string matching
SELECT  '{"tags":["urgent","pump"]}'::jsonb     @> '{"tags":["urgent"]}'::jsonb AS contains_urgent,  '{"priority":1}'::jsonb     @> '{"priority":"1"}'::jsonb AS number_is_not_string,  '{"a":{}}'::jsonb     @> '{"a":{}}'::jsonb AS contains_empty_object;

JSONB containment is type- and structure-aware. It is not substring matching. That precision is why GIN can map composite documents to searchable keys.

8. Planner selectivity and write cost

GIN improves read access by expanding each document into index keys/posting lists. That costs storage, WAL, insert/update work, vacuum maintenance, and possibly pending-list behavior. JSONB statistics also cannot understand every arbitrary nested predicate perfectly.

sql · index portfolio and table/index size evidence
SELECT indexrelid::regclass AS index_name,       pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,       idx_scanFROM pg_stat_user_indexesWHERE relid = 'app.ch17_profile'::regclassORDER BY indexrelid::regclass::text;SELECT pg_size_pretty(pg_table_size('app.ch17_profile')) AS table_size,       pg_size_pretty(pg_indexes_size('app.ch17_profile')) AS all_indexes;
Production judgment

Choose jsonb_ops when existence operators and broad JSONB search flexibility matter. Choose jsonb_path_ops when containment/jsonpath equality dominates and its narrower operator contract is acceptable. Prefer a relational/generated expression when one nested scalar becomes a first-class business predicate.

9. Checkpoint

Check your understanding

  1. Which operators does jsonb_path_ops not support?
  2. Why can two valid GIN indexes produce different size/search tradeoffs?
  3. Why did the transactionally dropped jsonb_ops index matter to the experiment?
  4. When is an expression GIN preferable to whole-document GIN?
  5. Why might a frequently filtered numeric JSON scalar deserve a typed column?
Review the answers

jsonb_path_ops omits ?, ?|, and ?&. It hashes value+path more specifically and indexes fewer operator semantics. Temporarily dropping jsonb_ops isolates path_ops capability. Expression GIN narrows indexing to a hot subdocument. A typed column offers stronger type constraints, ordinary statistics, and B-tree/range semantics for stable scalar predicates.

Authoritative references

These data types and index/operator contracts are version-sensitive. The lesson uses the PostgreSQL 18 primary documentation below.

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only assets compatible with the Ethereum/ERC-20 network. Do not send TRC-20/TRON assets.