GIN steht für „Generalized Inverted Index" — der Standard, wenn jede Zeile mehrere Werte hat, die einzeln gefunden werden sollen: ein Array enthält drei Tags, ein JSONB-Feld hat fünf Schlüssel, ein Text hat 50 Tokens. GIN indiziert jeden einzelnen Wert separat. Hier alle Use-Cases mit Operatoren und Trade-Offs.
Wie GIN funktioniert
Ein normaler B-tree-Index speichert pro Zeile einen Schlüssel — eine Zeile, ein Eintrag. GIN dreht das Verhältnis um: pro Wert wird gespeichert, in welchen Zeilen er vorkommt. Daher der Name „Inverted Index" — wie das Stichwortverzeichnis am Ende eines Buches.
Beispiel: Tabelle mit drei Artikeln, jeder hat ein Tag-Array.
Zeile 1: tags = ['postgres', 'sql', 'index']
Zeile 2: tags = ['postgres', 'jsonb']
Zeile 3: tags = ['sql', 'mysql']Ein B-tree könnte das Array nur als Ganzes vergleichen. Ein GIN-Index zerlegt jeden Eintrag in seine Einzelwerte und führt darüber Buch:
'postgres' → Zeilen 1, 2
'sql' → Zeilen 1, 3
'index' → Zeile 1
'jsonb' → Zeile 2
'mysql' → Zeile 3Eine Suche nach „alle Artikel mit Tag postgres" wird damit zum O(log n)-Lookup in der Index-Map — Postgres muss nicht durch jede Zeile laufen.
Genau diese Logik macht GIN für alles geeignet, was mehrere Werte pro Zeile hat: JSONB-Schlüssel, Array-Elemente, Volltext-Tokens, Trigram-Segmente. Im Folgenden die vier wichtigsten Use-Cases.
Use-Case 1: JSONB
JSONB-Spalten sind in modernen Postgres-Apps häufig — flexibles Event-Tracking, semi-strukturierte Daten, API-Payloads als Spalten. Das Problem ohne Index: Postgres muss jede Zeile auspacken und auf den gesuchten Schlüssel prüfen — ein Sequential Scan über teuren JSON-Parser. Bei 10 Millionen Events wird selbst eine simple Filter-Query zur Geduldsprobe.
GIN auf einer JSONB-Spalte indiziert jeden Schlüssel und (je nach Operator-Klasse) auch dessen Werte. Filter-Queries werden dadurch von Sekunden auf Millisekunden schnell.
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
data jsonb NOT NULL
);
CREATE INDEX events_data_idx ON events USING gin (data);Beispiel-Zeilen:
{ "user_id": 42, "event_type": "click", "page": "/home" }
{ "user_id": 17, "event_type": "view", "page": "/about" }
{ "user_id": 42, "event_type": "purchase","amount": 99.50 }Operatoren-Übersicht
| Operator | Bedeutung | Typische Frage |
|---|---|---|
? | Hat Schlüssel? | „Gibt's bei dem Event ein amount-Feld?" |
?| | Hat mindestens einen der Schlüssel? | „Hat das Event email ODER phone?" |
?& | Hat alle Schlüssel? | „Hat das Event user_id UND session_id?" |
@> | Enthält Sub-JSON? | „Ist das ein click-Event?" |
<@ | Ist Sub-JSON von? | seltener — eher in CHECK-Constraints |
Operatoren in der Praxis
? — Existenz eines Schlüssels. Liefert alle Events, die das Feld amount überhaupt haben (also typisch nur Käufe):
SELECT * FROM events WHERE data ? 'amount';@> — „enthält". Der wichtigste Operator, weil er Schlüssel und Wert prüft:
-- Alle Click-Events
SELECT * FROM events WHERE data @> '{"event_type":"click"}';
-- Alle Click-Events von User 42
SELECT * FROM events
WHERE data @> '{"event_type":"click","user_id":42}';@> heißt wörtlich „die linke Seite enthält die rechte". Du beschreibst das Muster, das du finden willst — Postgres findet alle Zeilen, deren JSONB dieses Muster vollständig abdeckt.
?| und ?& — Mehrfach-Schlüssel-Prüfung. Praktisch für „mindestens eins davon" oder „alle davon":
-- Events mit mindestens einem Identifikator
SELECT * FROM events
WHERE data ?| array['user_id', 'session_id'];
-- Events mit beiden Identifikatoren
SELECT * FROM events
WHERE data ?& array['user_id', 'session_id'];Ohne GIN-Index wären solche Queries Vollscans über die ganze Tabelle. Mit GIN: Index-Lookup, dann gezieltes Lesen der relevanten Zeilen.
Mehr Pattern und Performance-Vergleiche im Artikel JSONB-Indexes.
jsonb_path_ops für kompakteren Index
Die Default-Operator-Klasse jsonb_ops deckt alle Operatoren (?, ?|, ?&, @>, <@) ab — was sie groß macht, weil pro Schlüssel und pro Wert ein Index-Eintrag entsteht. Die alternative Operator-Klasse jsonb_path_ops macht's anders: sie hasht den ganzen Pfad-Wert-Pfad zusammen und speichert nur einen Eintrag pro JSON-Pfad.
CREATE INDEX events_data_path_idx
ON events USING gin (data jsonb_path_ops);Eigenschaften:
- Kleiner: typisch 2–3× weniger Speicher als der Default-Index
- Schneller bei
@>: weniger Index-Einträge zu durchsuchen - Eingeschränkt: unterstützt nur
@>—?,?|,?&funktionieren mit diesem Index nicht
Pragmatisch: in den allermeisten Production-Workloads ist @> der einzige relevante Operator (klassische „Filter nach Feldwert"-Queries). Wer Schlüssel-Existenz separat prüfen will, kann beides kombinieren — einen jsonb_path_ops-Index und einen kleineren Expression-Index für die Schlüssel-Existenz. Mehr im Artikel JSONB-Indexes.
Use-Case 2: Arrays
PostgreSQL erlaubt Array-Spalten direkt — text[], int[], alles geht. Ein typischer Anwendungsfall: Tags an einem Blog-Artikel, Skill-Listen an einem User-Profil, erlaubte Rollen für eine Resource. Statt einer separaten Junction-Tabelle (article_tags mit FK auf article und tag) hält man die Werte direkt im Array — kompakter Schema, keine Joins.
Das Problem: ohne Index ist die Frage „welche Artikel haben den Tag postgres?" ein Sequential Scan, der jedes Array auspackt und durchgeht. GIN indiziert jedes einzelne Array-Element separat — ein Lookup, fertig.
CREATE TABLE articles (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tags text[] NOT NULL
);
CREATE INDEX articles_tags_idx ON articles USING gin (tags);Beispiel-Daten:
Zeile 1: tags = ['postgres', 'sql', 'index']
Zeile 2: tags = ['postgres', 'jsonb']
Zeile 3: tags = ['mysql', 'replication']Die drei Array-Operatoren
| Operator | Bedeutung | Typische Frage |
|---|---|---|
@> | Enthält | „Hat der Artikel ALLE diese Tags?" |
<@ | Ist enthalten in | „Sind die Tags ein Subset?" |
&& | Überlappt | „Haben sie MINDESTENS einen gemeinsamen Tag?" |
In der Praxis
@> — „enthält alle". Liefert Artikel, die jeden gefragten Tag haben:
-- Artikel mit Tag 'postgres'
SELECT * FROM articles WHERE tags @> ARRAY['postgres'];
-- → Zeilen 1, 2
-- Artikel mit BEIDEN Tags 'postgres' und 'sql'
SELECT * FROM articles WHERE tags @> ARRAY['postgres', 'sql'];
-- → Zeile 1&& — „überlappt". Liefert Artikel, die mindestens einen der Tags haben:
-- Artikel mit 'sql' ODER 'jsonb'
SELECT * FROM articles WHERE tags && ARRAY['sql', 'jsonb'];
-- → Zeilen 1, 2<@ — „ist Subset von". Selterner — typisch in Constraint-Logik („nur diese Tags sind erlaubt"):
-- Artikel, deren Tags alle in der erlaubten Liste sind
SELECT * FROM articles
WHERE tags <@ ARRAY['postgres', 'sql', 'index', 'jsonb', 'orm'];
-- → Zeilen 1, 2 (Zeile 3 hat 'mysql', das nicht in der Liste ist)Was NICHT funktioniert
= ANY(tags) ist syntaktischer Zucker, aber nutzt den GIN-Index nicht — Postgres optimiert das nicht zu @>:
-- nutzt den GIN-Index NICHT
SELECT * FROM articles WHERE 'postgres' = ANY(tags);
-- Idiomatisch und Index-fähig:
SELECT * FROM articles WHERE tags @> ARRAY['postgres'];Pflicht-Pattern: bei Tag-Suche immer @>, nicht ANY.
Use-Case 3: Volltext-Suche
Volltext-Suche bedeutet: in einem Fließtext nach einzelnen Wörtern suchen, dabei Beugungen, Stoppwörter und Sprachregeln berücksichtigen. Klassisch ist das ein Job für externe Engines wie Elasticsearch — Postgres kann das aber nativ, und mit GIN-Index richtig schnell.
Das Konzept basiert auf zwei Datentypen:
tsvector— eine vorverarbeitete Repräsentation des Texts: Wörter sind auf Wortstämme reduziert (Stemming), Stoppwörter entfernt, Position und Gewichtung gespeichert.tsquery— eine Suchanfrage in der gleichen normalisierten Form.
Der Match-Operator @@ prüft, ob ein tsvector einer tsquery entspricht. GIN indiziert die einzelnen Tokens im tsvector — wie das Stichwortverzeichnis eines Buches.
CREATE TABLE blog_posts (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
body text NOT NULL,
search tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('german', title), 'A') ||
setweight(to_tsvector('german', body), 'B')
) STORED
);
CREATE INDEX blog_posts_search_idx ON blog_posts USING gin (search);Was im Schema steckt:
to_tsvector('german', text)— zerlegt den Text mit dem deutschen Wörterbuch in Tokens, reduziert Wortstämme („laufen", „lief", „läuft" →lauf)setweight(..., 'A')— markiert Title-Tokens mit Gewicht A (höchste Relevanz), Body mit B. Bei der Sortierung der Treffer werden Title-Treffer höher gewichtet.STORED— der Wert wird in der Tabelle gespeichert (nicht bei jedem Lesezugriff neu berechnet)- GIN-Index auf der Spalte — Tokens sind direkt indiziert
Suche durchführen
-- Einfache Wort-Suche
SELECT title FROM blog_posts
WHERE search @@ to_tsquery('german', 'postgres');
-- Phrase-Suche und Boolean-Logik
SELECT title FROM blog_posts
WHERE search @@ websearch_to_tsquery('german', 'postgres index -mysql');
-- Mit Ranking: relevanteste Treffer zuerst
SELECT title, ts_rank(search, query) AS rank
FROM blog_posts, websearch_to_tsquery('german', 'postgres index') query
WHERE search @@ query
ORDER BY rank DESC
LIMIT 10;websearch_to_tsquery ist der praxistauglichste Weg — versteht Google-artige Syntax: Quotes für Phrasen ("genau diese phrase"), Minus für Ausschluss (-mysql), Großbuchstaben-OR als Disjunktion. ts_rank liefert pro Treffer einen Relevanz-Score, der die Position und Gewichtung der Tokens berücksichtigt — perfekt für „Top 10 relevanteste Artikel".
Bei einer Tabelle mit Millionen Blog-Posts und einem GIN-Index läuft die Suche in einstelligen Millisekunden. Ohne Index wären's mehrere Sekunden.
Use-Case 4: Trigram-Suche mit pg_trgm
Volltext-Suche ist mächtig, aber sie operiert auf Wort-Ebene — LIKE '%hans%' würde sie nicht abdecken (das ist Substring-Suche, kein Wort-Match). Genau für diesen Fall: die Extension pg_trgm kombiniert mit GIN.
pg_trgm zerlegt jeden Text in Trigramme — Drei-Zeichen-Segmente — und indiziert die. Das Wort hans ergibt Trigramme h, ha, han, ans, ns (führende und folgende Leerzeichen sind Teil der Logik). Eine Substring-Suche wie '%hans%' wird damit zur Suche „enthält die Trigramme han und ans" — was der GIN-Index in O(log n) beantwortet.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL
);
CREATE INDEX users_name_trgm_idx
ON users USING gin (name gin_trgm_ops);Die Operator-Klasse gin_trgm_ops schaltet die Trigram-Logik ein — ohne sie würde GIN den Text als Ganzes indizieren (was sinnlos wäre).
Drei nützliche Operatoren
| Operator | Bedeutung | Use-Case |
|---|---|---|
LIKE / ILIKE | Standard-SQL-Pattern | Wildcards, auch %suffix% |
% | Ähnlichkeit ≥ Schwellwert | Tippfehler-tolerante Suche |
<-> | Distanz (1 - Ähnlichkeit) | Ranking nach „Nähe" |
Substring-Suche
-- Findet 'Hans Müller', 'Johansson', 'Hanseat'
SELECT * FROM users WHERE name ILIKE '%hans%';Klassisch wäre das ein Sequential Scan über die ganze Tabelle. Mit Trigram-Index: schnelle Index-Lookup auf die Trigramme han und ans, dann gezielte Verifikation der Treffer.
Fuzzy-Suche bei Tippfehlern
-- User tippt 'Hanss' (Tippfehler) — findet trotzdem 'Hans'
SELECT name FROM users WHERE name % 'Hanss';
-- Mit Distanz-Sortierung — beste Treffer zuerst
SELECT name, name <-> 'Hanss' AS distance
FROM users
WHERE name % 'Hanss'
ORDER BY distance
LIMIT 5;% matched alle Werte mit ausreichender Ähnlichkeit (Default-Schwellwert: 0.3). Mit SET pg_trgm.similarity_threshold = 0.5 lässt sich das verschärfen. Klassischer Use-Case: „Did you mean…?"-Vorschläge bei Such-Eingaben.
Wann GIN vs. GiST mit pg_trgm?
pg_trgm funktioniert mit beiden Index-Typen. GIN ist beim Suchen schneller, GiST beim Schreiben — also Standardregel: GIN für read-heavy, GiST für write-heavy. Bei Suche-Funktionalität in einer User-Tabelle, die selten geupdated wird: GIN.
Performance — Schreib-Last
GIN-Indexe sind beim Schreiben deutlich langsamer als B-tree:
- Pro Insert müssen alle Einzel-Werte indiziert werden (10 Tags = 10 Index-Einträge)
- Default-Pflege per
fastupdatepuffert kleine Änderungen, was bei großen Bulks helfen kann gin_pending_list_limitsteuert den Puffer
Faustregel: GIN bei lese-lastigen Workloads (Suche, Reports). Bei sehr schreib-lastigen Tabellen vorsichtig.
ALTER INDEX articles_tags_idx SET (fastupdate = on);
ALTER INDEX articles_tags_idx SET (gin_pending_list_limit = 4096);Besonderheiten
GIN ist langsam beim Schreiben — Trade-off bewusst.
Pro Index-Eintrag mehrere Operationen, weil jedes Element separat gehasht und ins Inverted Index geschrieben wird. Bei JSONB mit 50 Schlüsseln pro Zeile wird der Insert merklich langsamer. Bei Bulk-Loads fastupdate aktivieren oder Indexe nach Load anlegen.
jsonb_path_ops für reine @>-Workloads.
Spart Speicher (~2-3× kleiner) und beschleunigt die @>-Abfrage. Aber: ?, ?|, ?& funktionieren dann NICHT. Wer beides braucht: zwei separate GIN-Indexe — was selten lohnt.
GIN auf Array-Spalten ersetzt oft Junction-Tables.
Statt einer separaten article_tags-Tabelle mit FK kann ein tags text[] mit GIN-Index ähnlich performant sein — und sehr viel einfacher. Trade-off: keine Constraints auf den Tags, keine FK-Integrität. Bei freien Tag-Systemen okay, bei strikten Domains nicht.
Volltext-Suche braucht IMMER GIN — nicht GiST.
Beide unterstützen tsvector, aber GIN ist bei reiner Suche deutlich schneller. GiST nur, wenn du auch Updates am tsvector häufig hast (selten der Fall, da meist Generated Columns).
pg_trgm macht LIKE %suffix% schnell.
Vor dem Extension: LIKE '%foo%' = Sequential Scan. Mit Trigram-Index: schnelle Suche auch bei Substrings. Die Extension ist in jedem Postgres-Default verfügbar — CREATE EXTENSION pg_trgm.
GIN ist groß — nicht alles indizieren.
Bei JSONB lieber gezielte Pfad-Indexe ((data->>'event_type')) als kompletter GIN auf der Spalte. Mehr im Artikel JSONB-Indexes.