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:
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 msKnapp 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:
CREATE INDEX products_attrs_idx ON products USING gin (attrs);Damit wird die obige Query Millisekunden-schnell:
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:
| Operator | Mit 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:
| Klasse | Operatoren | Größe | Speed |
|---|---|---|---|
jsonb_ops (Default) | @>, ?, ?|, ?&, @?, @@ | groß | mittel |
jsonb_path_ops | nur @>, @?, @@ | ca. 1/3 kleiner | schneller |
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:
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
statusUNDcategoryindiziert, 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
-- 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:
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-Case | Empfehlung |
|---|---|
Wenige Hot-Spot-Schlüssel, oft =-gefiltert | Expression-Indexe pro Schlüssel |
| Viele unterschiedliche Containment-Queries | GIN mit jsonb_path_ops |
| Containment + Schlüssel-Existenz nötig | GIN mit jsonb_ops (Default) |
| Numerische Vergleiche oder Bereiche | Expression-Index mit Cast |
| Volltextsuche im JSON-Wert | GIN auf to_tsvector(jsonb_value) |
| Sehr große Tabelle, eingeschränkter Use-Case | Partial 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).
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
- GIN Indexes – PostgreSQL Documentation
- Built-in GIN Operator Classes
- Expression Indexes
- Partial Indexes
- pg_stat_user_indexes