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:

  1. Im Index die passenden Tupel-IDs finden
  2. 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:

SQL
CREATE INDEX orders_lookup_idx
ON orders (customer_id, created_at, total);

Damit kann eine Query wie:

SQL
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

SQL
CREATE INDEX orders_customer_covering_idx
ON orders (customer_id) INCLUDE (created_at, total);

Was sich ändert:

  • customer_id ist Sortier-Schlüssel (B-tree-Ebene)
  • created_at und total sind 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:

SQL
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:

SQL
CREATE UNIQUE INDEX customers_email_with_name_idx
ON customers (email) INCLUDE (name);

Eindeutigkeit gilt nur auf emailname 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).

SQL
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.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Indexes

Zur Übersicht