CREATE INDEX blockt standardmäßig alle Schreib-Zugriffe auf die Tabelle — bei großen Tabellen Stundenfall. CONCURRENTLY macht den Build live: Reader und Writer sind parallel erlaubt. Dafür dauert's länger, ist nicht-transaktional und kann fehlschlagen — mit einem INVALID-Index als Folge. Hier alle Details.

Die Grundform

SQL
CREATE INDEX CONCURRENTLY orders_customer_id_idx
ON orders (customer_id);

Was passiert hinter den Kulissen:

  1. Postgres scannt die Tabelle einmal komplett (mit SHARE UPDATE EXCLUSIVE Lock — Reader und Writer dürfen weiter)
  2. Baut eine erste Version des Index aus dem Snapshot
  3. Wartet auf alle Transaktionen, die zu Beginn liefen
  4. Macht einen zweiten Pass, um in der Zwischenzeit eingefügte/geänderte Zeilen einzuholen
  5. Markiert den Index als valide

Insgesamt: deutlich langsamer als ein normales CREATE INDEX, aber kein Service-Stop.

Einschränkungen

CONCURRENTLY hat Einschränkungen:

  • Nicht in Transaktionen erlaubt — muss als Standalone-Statement laufen, kein BEGIN; CREATE INDEX CONCURRENTLY ... ; COMMIT;
  • Höhere Last während des Builds — Tabelle wird zweimal gescannt
  • Längere Build-Zeit — typisch 2-3× länger als ein normaler Index-Build
  • Kann fehlschlagen — z. B. bei Konflikten in einer UNIQUE-Constraint

Migrations-Tools brauchen oft Spezial-Handling: golang-migrate und Co. wickeln Migrationen sonst in Transaktionen, was hier explizit deaktiviert werden muss.

Was passiert bei Abbruch?

Wenn der Build abstürzt — z. B. Verbindung weg, Server-Restart, Constraint-Konflikt — bleibt ein INVALID-Index zurück:

SQL INVALID-Indexe finden
SELECT
    i.relname AS index_name,
    t.relname AS table_name
FROM pg_index ix
JOIN pg_class i ON i.oid = ix.indexrelid
JOIN pg_class t ON t.oid = ix.indrelid
WHERE NOT ix.indisvalid;

INVALID-Indexe werden vom Planner nicht genutzt — sind also ohne Wert. Sie müssen aufgeräumt werden, bevor ein erneuter Build versucht wird.

INVALID-Index aufräumen

SQL
DROP INDEX CONCURRENTLY orders_customer_id_idx;

Auch DROP INDEX CONCURRENTLY blockt nicht — sehr praktisch.

Danach: erneut versuchen.

SQL
CREATE INDEX CONCURRENTLY orders_customer_id_idx
ON orders (customer_id);

Wer Glück hat: läuft beim zweiten Mal durch. Wer Pech hat: muss erst die Ursache finden (Constraint-Konflikt, etc.) und Daten bereinigen.

REINDEX CONCURRENTLY

Bestehende Indexe neu bauen, ohne Lock — z. B. um Bloat aufzuräumen oder einen Index nach Postgres-Major-Upgrade zu refreshen:

SQL
REINDEX INDEX CONCURRENTLY orders_customer_id_idx;
REINDEX TABLE CONCURRENTLY orders;       -- alle Indexe der Tabelle
REINDEX SCHEMA CONCURRENTLY public;     -- alle Indexe des Schemas

Verfügbar ab PG 12. Vorher musste man ein temporäres Index erstellen, dann tauschen.

UNIQUE-Indexe und Konflikte

Wenn der Build einen UNIQUE-Constraint baut, der von Bestandsdaten verletzt wird:

SQL
CREATE UNIQUE INDEX CONCURRENTLY users_email_uniq
ON users (email);

-- ERROR: could not create unique index "users_email_uniq"
-- DETAIL: Key (email)=(hans@example.com) is duplicated.

Folge: INVALID-Index, der gedroppt werden muss. Nach Bereinigung der Duplikate erneut versuchen.

Pragmatisch: bei großen Tabellen vorher prüfen mit:

SQL
SELECT email, count(*)
FROM users
GROUP BY email
HAVING count(*) > 1;

Constraints, die nicht CONCURRENTLY gehen

PRIMARY KEY und UNIQUE als Constraints (nicht als Index) brauchen ACCESS EXCLUSIVE. Workaround: erst Index CONCURRENTLY bauen, dann als Constraint registrieren.

SQL
-- 1. Unique-Index ohne Lock
CREATE UNIQUE INDEX CONCURRENTLY users_email_uniq_idx
ON users (email);

-- 2. Existierenden Index als Constraint registrieren (kurzer Lock)
ALTER TABLE users
ADD CONSTRAINT users_email_uniq
UNIQUE USING INDEX users_email_uniq_idx;

Schritt 2 ist instant — Postgres muss nichts mehr scannen, der Index ist schon da.

Häufige Stolperfallen

Keine Transaktion — Migrations-Tool muss das wissen.

CREATE INDEX CONCURRENTLY darf nicht in BEGIN; ... COMMIT; laufen. Migrations-Frameworks wie Flyway, Liquibase, golang-migrate haben dafür Markers (z. B. -- transactional: false oder eigene Schalter). Wer das vergisst: Fehler beim Start der Migration.

INVALID-Index nach Abbruch — unbedingt cleanen.

Ein abgebrochener Build hinterlässt einen INVALID-Index. Der Planner ignoriert ihn, kostet aber Speicher und Schreib-Performance. \d table zeigt's. Mit DROP INDEX CONCURRENTLY aufräumen, dann erneut.

Build dauert deutlich länger als ein normaler.

Faktor 2-3× ist typisch — dafür ohne Service-Stop. Bei riesigen Tabellen kann ein Build mehrere Stunden brauchen. Ist okay, weil keine Downtime; aber Erwartungs-Management mit dem Team.

Hohe Last während des Builds.

Postgres scannt die Tabelle zweimal und braucht Sort-Memory. Auf Production-DB mit hoher Schreib-Last kann das spürbar werden. In Off-Peak-Zeiten planen, falls möglich.

Lange laufende Transaktionen blockieren das Finalisieren.

Phase 3 (Warten auf Transaktionen) hängt, wenn eine Transaktion ewig offen ist. Symptom: Build steht nahe Ende, kommt aber nicht durch. Mit pg_stat_activity lange Transaktionen identifizieren und ggf. terminieren.

Bei UNIQUE: Bestand vorher prüfen.

Ein UNIQUE-Index bricht beim ersten Duplikat ab — und du hast einen INVALID-Index. Spar dir den Loop: vor dem Build mit GROUP BY ... HAVING count(*) > 1 prüfen und Duplikate bereinigen.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Indexes

Zur Übersicht