Eine jsonb-Spalte ohne Index ist auf großen Tabellen schnell ein Performance-Problem — jede @>- oder ?-Query führt zu einem Sequential Scan. PostgreSQL bietet zwei Index-Strategien für jsonb: einen breiten GIN-Index für viele Operatoren oder einen schmaleren Expression-Index für einzelne Schlüssel. Welcher passt, hängt davon ab, was du wirklich abfragst.

Das Problem ohne Index

Stellt dir 1 Million Produkte vor, jeder mit einem attrs-jsonb-Feld:

SQL
myapp=> EXPLAIN ANALYZE
        SELECT * FROM products
        WHERE attrs @> '{"color": "red"}';
                               QUERY PLAN
----------------------------------------------------------------
 Seq Scan on products  (cost=0.00..…)
   Filter: (attrs @> '{"color": "red"}'::jsonb)
   Rows Removed by Filter: 950000
 Planning Time: 0.123 ms
 Execution Time: 1842.567 ms

Knapp 2 Sekunden für eine simple Containment-Suche. Postgres muss jede Zeile parsen und prüfen.

GIN-Index — die Standardlösung

Generalized Inverted Index — speziell für „enthält”-artige Suchen designt:

SQL GIN auf der ganzen jsonb-Spalte
CREATE INDEX products_attrs_idx ON products USING gin (attrs);

Damit wird die obige Query Millisekunden-schnell:

SQL
myapp=> EXPLAIN ANALYZE
        SELECT * FROM products
        WHERE attrs @> '{"color": "red"}';
                                  QUERY PLAN
----------------------------------------------------------------
 Bitmap Heap Scan on products  (cost=… rows=49423)
   Recheck Cond: (attrs @> '{"color": "red"}'::jsonb)
   ->  Bitmap Index Scan on products_attrs_idx  (cost=…)
         Index Cond: (attrs @> '{"color": "red"}'::jsonb)
 Planning Time: 0.234 ms
 Execution Time: 12.481 ms

Über 100× schneller. GIN-Indexe können viele Operatoren bedienen:

OperatorMit GIN beschleunigt?
@> (Containment)ja
?, ?|, ?& (Schlüssel-Existenz)ja (mit Default-jsonb_ops)
@?, @@ (JSON Path)ja (mit Default-jsonb_ops)
->, ->> (Wert-Zugriff)nein — dafür Expression-Index

jsonb_ops vs. jsonb_path_ops

Beim GIN gibt’s zwei Operator-Klassen:

KlasseOperatorenGrößeSpeed
jsonb_ops (Default)@>, ?, ?|, ?&, @?, @@großmittel
jsonb_path_opsnur @>, @?, @@ca. 1/3 kleinerschneller
SQL Mit jsonb_path_ops
CREATE INDEX products_attrs_path_idx ON products
    USING gin (attrs jsonb_path_ops);

Faustregel:

  • Du brauchst nur Containment-Queries → jsonb_path_ops (kompakter, schneller).
  • Du brauchst auch Schlüssel-Existenz oder JSON-Path-Existence → Default-jsonb_ops.

Bei sehr großen Tabellen lohnt sich oft jsonb_path_ops plus separate Expression-Indexe für die Schlüssel, die mit ? abgefragt werden.

Expression-Index — für einzelne Schlüssel

Wenn du immer dieselbe Spalte abfragst (status, category), ist ein gezielter Expression-Index oft besser als der breite GIN:

SQL Expression-Index auf attrs->>'status'
CREATE INDEX products_status_idx ON products ((attrs->>'status'));

-- Diese Query nutzt ihn:
SELECT * FROM products WHERE attrs->>'status' = 'active';

Vorteile:

  • Klein: nur die Werte einer Spalte werden indiziert, nicht das ganze Dokument.
  • Schnell für =-Filter und Sortierungen.
  • Funktioniert mit allen B-Tree-Operatoren (=, <, >, IN, …).

Nachteile:

  • Funktioniert nur für einen Pfad. Wer status UND category indiziert, braucht zwei Indexe.
  • @>-Containment-Queries gehen nicht — dafür weiter GIN.

Häufige Kombination in Production: ein GIN-Index für allgemeine Filter plus 2-3 Expression-Indexe auf die hot-spot-Schlüssel.

Mehrere Expression-Indexe

SQL Pro Schlüssel ein Index
-- Status (häufig gefilterte Spalte)
CREATE INDEX products_status_idx ON products ((attrs->>'status'));

-- Category für Kategorie-Filter
CREATE INDEX products_category_idx ON products ((attrs->>'category'));

-- Numerischer Vergleich auf 'price'
CREATE INDEX products_price_idx ON products (((attrs->>'price')::numeric));

-- Mehrere Expression-Spalten in einem Index
CREATE INDEX products_status_category_idx ON products
    ((attrs->>'status'), (attrs->>'category'));

Bei numerischen Vergleichen wichtig: explizit casten ((attrs->>'price')::numeric), sonst landet der Wert als Text im Index — Vergleiche funktionieren dann lexikographisch (also '9' > '100', was Bugs auslöst).

Partial Indexe

Wenn nur ein kleiner Teil der Daten relevant ist, lohnt sich ein Partial Index:

SQL Nur aktive Produkte
CREATE INDEX products_active_attrs_idx
ON products USING gin (attrs)
WHERE attrs->>'status' = 'active';

Der Index ist viel kleiner und schneller, weil er nur ~10 % der Daten umfasst. Postgres nutzt ihn automatisch für Queries, die attrs->>'status' = 'active' als Bedingung enthalten — auch in Kombination mit Containment.

Wann welcher Index?

Use-CaseEmpfehlung
Wenige Hot-Spot-Schlüssel, oft =-gefiltertExpression-Indexe pro Schlüssel
Viele unterschiedliche Containment-QueriesGIN mit jsonb_path_ops
Containment + Schlüssel-Existenz nötigGIN mit jsonb_ops (Default)
Numerische Vergleiche oder BereicheExpression-Index mit Cast
Volltextsuche im JSON-WertGIN auf to_tsvector(jsonb_value)
Sehr große Tabelle, eingeschränkter Use-CasePartial Index mit WHERE-Klausel

Index-Pflege

GIN-Indexe sind etwas teurer beim Schreiben als B-Tree-Indexe. Für sehr Insert-lastige Workloads lohnt sich gin_pending_list_limit-Tuning oder das Verzögern via fastupdate = on (Standard ab PG 8.4).

SQL Index-Statistiken sehen
SELECT
    indexrelid::regclass AS index,
    pg_size_pretty(pg_relation_size(indexrelid)) AS size,
    idx_scan,
    idx_tup_read,
    idx_tup_fetch
FROM pg_stat_user_indexes
WHERE relname = 'products'
ORDER BY pg_relation_size(indexrelid) DESC;

Wenn ein Index idx_scan = 0 hat (nie genutzt), ist er meistens überflüssig — droppen.

Häufige Stolperfallen

->> mit numerischen Vergleichen ohne Cast — falscher Index.

WHERE attrs->>'price' > '100' ist ein String-Vergleich (lexikographisch). „99” > „100” wäre TRUE. Korrekt: (attrs->>'price')::numeric > 100 oder eine getypte Spalte. Bei Expression-Indexen muss der Cast in der Index-Definition derselbe sein wie in der Query, sonst wird der Index nicht genutzt.

GIN passt nicht zu Wert-Vergleichen oder Sortierungen.

GIN ist für „enthält”/„hat Schlüssel”. Für WHERE x->>'created_at' > '2026-01-01' oder ORDER BY x->>'price' braucht’s Expression-Index oder eine richtige Spalte. GIN verschwendet sich darauf nicht.

jsonb_path_ops verliert ?-Queries.

jsonb_path_ops ist kompakter und schneller, aber unterstützt nur @>-, @?-, @@-Operatoren. Wer attrs ? 'discount' häufig nutzt, muss bei der Default-Klasse bleiben — oder einen separaten Expression-Index auf den Schlüssel anlegen.

Index-Bloat bei häufigen Updates.

Wenn jsonb-Werte häufig geupdatet werden, blähen sich GIN-Indexe stärker auf als B-Tree. Regelmäßiges REINDEX (oder VACUUM als Mindestmaß) hält die Größe im Griff. In Praxis: meist erst ab Millionen-Updates pro Tag relevant.

Partial Index nur, wenn das WHERE konstant ist.

CREATE INDEX … WHERE attrs->>'status' = 'active' greift nur, wenn die Query selbst genau dieselbe Bedingung enthält (nicht ein Subset oder Superset). Mit dem Indexkonzept Vorsicht: ein zu spezifischer Partial Index ist oft ungenutzt.

Index-Build dauert auf großen Tabellen.

CREATE INDEX … USING gin (attrs) auf einer 10-Mio-Zeilen-Tabelle kann Stunden dauern. In Production immer mit CONCURRENTLY-Variante — kein langer Lock auf der Tabelle, aber etwas länger insgesamt.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu JSON & JSONB

Zur Übersicht