Ein Covering Index enthält alle Spalten, die eine Query braucht — Postgres muss die Tabelle gar nicht mehr anfassen. Mit der INCLUDE-Klausel ab PG 11 lassen sich zusätzliche Payload-Spalten in den Index legen, ohne dass sie zur Sortier- oder Sucheordnung beitragen.
Was ist ein Index-Only-Scan?
Ein normaler Index-Scan macht zwei Schritte:
- Im Index die passenden Tupel-IDs finden
- Mit den IDs in die Tabelle springen, um die Spalten-Werte zu lesen
Ein Index-Only-Scan überspringt Schritt 2 — alle benötigten Spalten sind schon im Index. Wenn die Visibility-Map sagt, dass die Page „all visible" ist, reicht der Index allein.
Ohne Index-Only-Scan: 2× I/O. Mit: 1× I/O. Bei großen Tabellen: spürbar schneller.
Klassischer Weg: Composite-Index als Cover
Vor PG 11 hat man einfach alle Spalten in den Index gepackt:
CREATE INDEX orders_lookup_idx
ON orders (customer_id, created_at, total);Damit kann eine Query wie:
SELECT created_at, total FROM orders WHERE customer_id = 42;…als Index-Only-Scan laufen.
Nachteil: created_at und total werden Teil des B-tree-Sortier-Schlüssels — der Index wird größer und tiefer. Beim Insert müssen sie auch sortiert eingefügt werden.
Mit INCLUDE — sauberer
CREATE INDEX orders_customer_covering_idx
ON orders (customer_id) INCLUDE (created_at, total);Was sich ändert:
customer_idist Sortier-Schlüssel (B-tree-Ebene)created_atundtotalsind nur Payload — im Index gespeichert, aber nicht sortiert
Vorteile:
- Index ist kompakter (nur eine Spalte als Tree-Schlüssel)
- Insert ist günstiger (nur eine Spalte zu sortieren)
- Index-Only-Scan funktioniert genauso
Wann lohnt INCLUDE?
Pragmatisch, wenn:
- Eine Query häufig läuft und viele Spalten zurückgibt
- Diese Spalten nicht zum Filtern oder Sortieren gebraucht werden
- Der Tabellen-Zugriff merklich Cost ist (große Tabelle, langsames Storage)
Beispiel: ein „Kunden-Dashboard"-Query, das pro Customer die letzten 50 Bestellungen mit Datum und Betrag listet:
CREATE INDEX orders_dashboard_idx
ON orders (customer_id, created_at DESC)
INCLUDE (total, status);
SELECT created_at, total, status
FROM orders
WHERE customer_id = 42
ORDER BY created_at DESC LIMIT 50;Plan-Auszug: Index Only Scan using orders_dashboard_idx. Die Tabelle wird nicht angefasst.
INCLUDE auch für UNIQUE
INCLUDE funktioniert auch mit UNIQUE-Constraints:
CREATE UNIQUE INDEX customers_email_with_name_idx
ON customers (email) INCLUDE (name);Eindeutigkeit gilt nur auf email — name ist nur Payload. Damit kann SELECT name FROM customers WHERE email = ... als Index-Only-Scan laufen.
Wann es nicht greift
Index-Only-Scan setzt voraus, dass die Visibility Map die Page als „all visible" markiert. Das passiert nach VACUUM. Bei häufigen Updates ist die Visibility Map oft veraltet — Postgres muss doch in die Tabelle schauen.
Symptom: Plan zeigt Index Only Scan, aber Heap Fetches: 12345 (also doch Tabellen-Zugriffe).
EXPLAIN (ANALYZE, BUFFERS)
SELECT created_at, total FROM orders WHERE customer_id = 42;Wenn Heap Fetches hoch ist: VACUUM auf die Tabelle. Bei dauerhaft hohen Heap-Fetches: Auto-Vacuum-Tuning.
INCLUDE-Limits
- INCLUDE-Spalten dürfen nicht in der WHERE/ORDER BY-Klausel verwendet werden, ohne Tabellen-Lookup
- INCLUDE-Spalten zählen zur Index-Größe — nicht beliebig viele aufnehmen
- Funktioniert mit B-tree, GiST, SP-GiST, GIN ab PG 14 — nicht mit Hash
Besonderheiten
INCLUDE spart Index-Größe vs. Composite — bei hohen Sortier-Tiefen merklich.
(a, b, c) bringt drei Spalten in den B-tree-Sortier-Pfad — jeder Insert vergleicht alle drei. (a) INCLUDE (b, c) hat nur a als Sort-Key, b und c als Payload. Schnellere Inserts, kleinerer Tree.
Index-Only-Scan braucht aktuelle Visibility Map.
Pläne sagen Index Only Scan, aber Heap Fetches: 1000+ heißt: Postgres muss doch in die Tabelle. Ursache: Visibility Map veraltet. VACUUM (oder Auto-Vacuum-Tuning) hilft.
INCLUDE-Spalten sind nicht filterbar im Index.
WHERE c = 5 mit Index (a) INCLUDE (c) nutzt den Index nicht für den Filter — c ist nur Payload. Erst in der äußeren Filter-Stufe geht's. Wer auch nach c filtern will: (a, c) als Sort-Key, oder zusätzlicher Index.
Abwägung: INCLUDE-Spalten verbrauchen Speicher.
Wer viel Payload draufpackt, hat einen großen Index. Bei einer 200-Byte-Spalte und 100 Mio. Zeilen: 20 GB extra. Nur INCLUDE'n, was wirklich häufig in den SELECT-Listen erscheint.
INCLUDE kombiniert sich mit Partial Index.
CREATE INDEX ... ON t (a) INCLUDE (b, c) WHERE status = 'active' — kompakt, fokussiert, deckt nur die hot Path-Queries ab. Sehr effektiv für „Hot-Workloads" auf Tabellen mit lange historie.
Bei breiten Tabellen Generated Columns als Index-Pfad.
Wer einen Index-Only-Scan auf einer berechneten Spalte will: Generated Column + INCLUDE-Index. Ist sauberer als Expression-Index, weil der Spalten-Wert auch direkt im SELECT-Result auftauchen kann.