Start with real query shapes, not a blanket GIN index. Use default jsonb_ops when key existence matters, jsonb_path_ops for a containment-heavy workload, and B-tree expression or ordinary columns for frequently filtered scalars, ranges, ordering, and uniqueness. Validate with representative data and EXPLAIN (ANALYZE, BUFFERS), then create large indexes concurrently.
Use JSONB for flexibility, not for every field
PostgreSQL recommends jsonb for most application JSON because it avoids reparsing and supports indexing. The same official JSON type guidance recommends a reasonably predictable document structure and warns that updating a JSON document locks the whole row. That makes a hybrid model the practical default.
CREATE TABLE integration_events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id uuid NOT NULL,
source text NOT NULL,
event_type text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
attributes jsonb NOT NULL,
CONSTRAINT attributes_is_object
CHECK (jsonb_typeof(attributes) = 'object')
);Keep identity, ownership, lifecycle state, money, timestamps used for retention, foreign keys, and other invariant business fields in typed columns. Use JSONB for provider-specific attributes, optional configuration, evolving metadata, or payload fragments whose keys genuinely vary. This preserves constraints and predictable query plans without forcing every integration into one rigid table.
For multi-tenant products, keep tenant_id relational and include it in every tenant-scoped query. Pair that rule with our PostgreSQL row-level security guide when the database must enforce tenant isolation.
Choose the index from the operator and query shape
An index is usable only when PostgreSQL can match the query operator and expression to an index operator class. “This key is inside JSON” is not enough. Write the production query first, load representative data, and then choose the narrowest useful index.
| Frequent query | First index to evaluate | Important trade-off |
|---|---|---|
Containment across many possible keys with @> | GIN on the JSONB column | Flexible reads; indexes every qualifying document value |
Top-level key or array-element existence with ?, ?|, or ?& | GIN with default jsonb_ops | jsonb_path_ops cannot serve existence operators |
| Containment and JSONPath only | GIN with jsonb_path_ops | Usually smaller and more specific, but supports fewer operators |
| Equality on one frequently queried scalar | B-tree expression index | Query expression must match; adds write cost |
| Tenant filter plus scalar filter and recent-first ordering | Composite B-tree expression index | Purpose-built and less flexible |
| Small, stable subset of rows | Partial index | Query must imply the index predicate |
Choose jsonb_ops or jsonb_path_ops deliberately
A default GIN index supports containment, JSONPath matching, and top-level key-existence operators:
CREATE INDEX integration_events_attributes_gin
ON integration_events USING GIN (attributes);
SELECT id, received_at
FROM integration_events
WHERE tenant_id = $1
AND attributes @> '{"region":"eu","priority":"high"}'::jsonb;
SELECT id
FROM integration_events
WHERE attributes ? 'external_reference';The PostgreSQL GIN operator-class reference lists @>, @?, @@, ?, ?|, and ?& for jsonb_ops. The operator must apply to the indexed value. For example, an index on attributes does not directly make attributes -> 'tags' ? 'urgent' indexable through that existence operator.
Use jsonb_path_ops for containment-heavy workloads
CREATE INDEX integration_events_attributes_path_gin
ON integration_events USING GIN (attributes jsonb_path_ops);
SELECT id
FROM integration_events
WHERE attributes @> '{"provider":{"account_tier":"pro"}}'::jsonb;PostgreSQL's JSONB indexing documentation explains that jsonb_path_ops supports @>, @?, and @@, but not the existence operators. It is usually smaller and produces more specific searches than default jsonb_ops. It also creates no index entries for structures containing no values, such as {"a": {}}, so searches for those structures can require a slow full-index scan.
Do not keep both operator classes “just in case.” Every extra index consumes storage and increases insert, update, vacuum, backup, and cache pressure. Compare them against captured production-shaped queries and choose the operator coverage you actually need.
Use expression indexes for hot scalar paths
When an application repeatedly filters one JSON scalar, a targeted B-tree expression index can be smaller and more useful than indexing the entire document. PostgreSQL supports indexes over scalar expressions, but its expression-index documentation notes that the expression must be computed during inserts and non-HOT updates.
CREATE INDEX integration_events_external_ref_idx
ON integration_events ((attributes ->> 'external_reference'));
SELECT id, source
FROM integration_events
WHERE attributes ->> 'external_reference' = $1;The query uses the same expression as the index. Avoid hiding it behind inconsistent casts or functions across code paths. If the value has durable domain meaning, needs a foreign key or unique constraint, or participates in many queries, promote it to a typed column instead.
Combine tenant scope, a JSON scalar, and sort order
CREATE INDEX integration_events_tenant_category_recent_idx
ON integration_events (
tenant_id,
(attributes ->> 'category'),
received_at DESC,
id DESC
);
SELECT id, received_at, attributes
FROM integration_events
WHERE tenant_id = $1
AND attributes ->> 'category' = $2
ORDER BY received_at DESC, id DESC
LIMIT 50;This index matches a common SaaS access path: tenant equality, category equality, then deterministic recent-first pagination. The final id breaks timestamp ties. It does not accelerate arbitrary JSON filters, and that is a strength: its cost and purpose are explicit.
Index a nested array only when that path is hot
CREATE INDEX integration_events_tags_gin
ON integration_events USING GIN ((attributes -> 'tags'));
SELECT id
FROM integration_events
WHERE attributes -> 'tags' ? $1;The official JSONB guide uses this pattern because the existence operator now applies directly to the indexed expression. It can be substantially narrower than a whole-document GIN index when only tags drives discovery.
Use partial indexes for stable, selective subsets
A partial index stores only rows satisfying its predicate. That can help a queue or operations view where pending records are a small subset and completed history dominates.
CREATE INDEX integration_events_pending_recent_idx
ON integration_events (tenant_id, received_at DESC)
WHERE attributes @> '{"processing_status":"pending"}'::jsonb;
SELECT id, received_at
FROM integration_events
WHERE tenant_id = $1
AND attributes @> '{"processing_status":"pending"}'::jsonb
ORDER BY received_at DESC
LIMIT 100;PostgreSQL can use a partial index only when it can prove that the query condition implies the predicate. The partial-index documentation warns that PostgreSQL is not a general theorem prover; predicates should match the intended queries closely, and parameterized predicate clauses can prevent recognition.
If processing status changes frequently or must be validated, model it as a typed column and build the partial index on that column. JSONB should not conceal a core state machine merely to avoid a migration.
Validate with actual plans and workload evidence
Build a test table with realistic row counts, document sizes, key frequency, tenant skew, and status distribution. Tiny fixtures cannot reveal whether a common value makes an index unattractive or whether a GIN search produces many rechecks.
ANALYZE integration_events;
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, received_at
FROM integration_events
WHERE tenant_id = '00000000-0000-0000-0000-000000000001'
AND attributes @> '{"region":"eu","priority":"high"}'::jsonb
ORDER BY received_at DESC
LIMIT 50;EXPLAIN ANALYZE executes the statement and reports actual rows and timing; BUFFERS exposes cache activity. PostgreSQL's EXPLAIN guide recommends comparing estimated and actual row counts. Large differences can point to stale statistics, skew, correlated predicates, or a model the planner cannot estimate well. Never run EXPLAIN ANALYZE on a write in production unless executing that write is intentional and safe.
Use the official pg_stat_statements extension to identify normalized statements accumulating the most total execution time, calls, rows, or I/O. Optimize the workload that consumes resources, not a single query copied from a quiet development database.
Roll indexes out without turning the migration into an outage
- Capture the baseline. Save query text, parameters or distributions, plans, latency, index sizes, write throughput, and database load.
- Test representative data. Confirm operator compatibility and measure both reads and writes.
- Create large indexes concurrently. Use
CREATE INDEX CONCURRENTLYin its own migration step; it cannot run inside a transaction block. - Verify validity and usage. Check
pg_index.indisvalid, observe real query plans, and allow for workload cycles before judging value. - Remove redundancy carefully. Confirm no important query, constraint, or operational job depends on the old index before a concurrent drop.
CREATE INDEX CONCURRENTLY integration_events_attributes_path_gin
ON integration_events USING GIN (attributes jsonb_path_ops);
SELECT indexrelid::regclass AS index_name, indisvalid, indisready
FROM pg_index
WHERE indexrelid =
'integration_events_attributes_path_gin'::regclass;The concurrent index documentation explains that writes can continue, but concurrent builds require more work and can leave an invalid index after failure. Our zero-downtime PostgreSQL migration guide covers transaction boundaries, invalid-index cleanup, lock budgeting, and expand-contract delivery in more detail.
Database indexes also compete with application connections and memory. Review our Node.js database connection-pooling guide when query concurrency, not one query plan, is the source of saturation.
Keep JSONB queries inside the same security boundaries
JSONB does not weaken the need for tenant filters, row-level authorization, least-privilege roles, statement timeouts, and bounded result sets. Bind user input as parameters rather than concatenating JSON literals, paths, operators, sort expressions, or SQL fragments. Validate allowed filter keys in application code and map them to reviewed query templates.
const sql =
"SELECT id, received_at, attributes " +
"FROM integration_events " +
"WHERE tenant_id = $1 AND attributes @> $2::jsonb " +
"ORDER BY received_at DESC, id DESC LIMIT 50";
const filter = { region: input.region, priority: input.priority };
const result = await pool.query(sql, [tenantId, JSON.stringify(filter)]);The parameterized value is data, not executable SQL. Still validate size, nesting depth, key allowlists, and product semantics before it reaches the database. Add timeouts and pagination so an authenticated user cannot turn a flexible filtering endpoint into an unbounded resource query.
Endurance Softwares supports repository-verified full-stack application and PostgreSQL engineering and Node.js API development for teams aligning data models, query contracts, security, and production operations.
Avoid common JSONB indexing mistakes
- One universal GIN index. It may be useful, but it is not automatically cheaper than targeted expression indexes or typed columns.
- Operator mismatch.
jsonb_path_opscannot answer key-existence queries, and a whole-column GIN index may not match an operator applied to a nested expression. - Core fields hidden in JSON. Tenant IDs, statuses, timestamps, money, and relationships lose straightforward constraints and indexing.
- Unsafe casts. Casting arbitrary JSON text to numeric or timestamp can fail when one document contains malformed data. Validate on write or promote the field.
- Indexes created before query contracts. Slightly different extraction, casting, filtering, or ordering can make a carefully built index irrelevant.
- Testing only warm, selective queries. Include cache-cold behavior, common values, absent keys, large documents, and write-heavy periods.
- Skipping lifecycle cost. Measure storage, backups, replication, insert and update latency, vacuum work, and recovery—not only one SELECT.
- Forcing index scans. A sequential scan is often the right plan when much of a table must be read.
PostgreSQL JSONB indexing checklist
✓ Keep invariant and relational fields in typed columns
✓ Document frequent query operators and sort orders
✓ Use jsonb_ops when existence operators matter
✓ Test jsonb_path_ops for containment-heavy searches
✓ Prefer targeted expression indexes for hot scalar paths
✓ Make tenant scope explicit in schema and queries
✓ Use partial indexes only for stable, selective predicates
✓ Validate with representative data and actual plans
✓ Measure index write, storage, vacuum, and cache costs
✓ Build large production indexes concurrently and verify validity
Frequently asked questions
Should every JSONB column have a GIN index?
No. Index only query paths justified by a measured workload. Write-heavy tables, small tables, archival payloads, or JSON kept only for retrieval may be better without a GIN index.
Is jsonb_path_ops always faster than jsonb_ops?
No. It is typically smaller and more specific for its supported containment and JSONPath operators, but it does not support key-existence operators and performs poorly for searches involving structures without values. Operator coverage comes first.
When should a JSON key become a column?
Promote it when it carries stable business meaning, requires a type or constraint, participates in relationships, drives frequent filters or ordering, or must be updated independently. JSONB and relational columns work best together.
Can an index fix every slow JSONB query?
No. Slow queries can come from weak selectivity, stale estimates, excessive result sizes, joins, sorts, connection saturation, disk pressure, lock contention, or an unsuitable data model. Read the complete plan and the workload context.
Design the query contract before the index
A durable PostgreSQL design makes flexible metadata useful without surrendering constraints, predictable access paths, or operational safety.
Discuss your PostgreSQL application architecture