Duplikate finden mit SQL — von COUNT(*) > 1 bis zusammengesetzte Schlüssel

GROUP BY … HAVING count(*) > 1 ist vielleicht das meistgetippte SQL-Snippet der Welt — und trotzdem stolpert es an zwei Stellen, die kaum jemand auf dem Schirm hat: bei zusammengesetzten Schlüsseln und bei NULL. Ausgerechnet beim Umgang mit fehlenden Werten in einer eindeutigen Spalte sind sich SQL Server und Postgres nämlich uneinig — der eine erlaubt genau ein NULL, der andere beliebig viele. Wer Duplikate finden will und das nicht weiß, meldet nach einer Migration plötzlich „Duplikate“, die vorher völlig legal waren.

Das Wichtigste vorab:

  • Die Grundform GROUP BY … HAVING count(*) > 1 findet doppelte Schlüssel — aber sie ist eigentlich eine Kardinalitätsprüfung: ein Schlüssel darf höchstens n-mal vorkommen (> 1 für echte Eindeutigkeit, > n für Mengen-Regeln).
  • Zusammengesetzte Schlüssel prüft man mit einem Mehrspalten-GROUP BY — die Vollständigkeit der Spaltenliste entscheidet, nicht ihre Reihenfolge.
  • Die NULL-FalleGROUP BY fasst alle NULL zu einer Gruppe zusammen und meldet sie als Duplikat — während Postgres in einer UNIQUE-Spalte standardmäßig mehrere NULL erlaubt und SQL Server genau eines. Das ist migrationsrelevant.
  • Eine Treffer-Liste der Schlüssel reicht nicht: für die Fehlertabelle protokolliert man jedes betroffene Vorkommen, nicht nur das erste.

Voraussetzung: Postgres als Beispiel-Engine und das gemeinsame Prüfgerüst aus dem Framework-Artikel — eine zentrale Fehlertabelle dq.error, in die jede Verletzung eine Zeile schreibt, und eine Staging-Schicht, gegen die geprüft wird. Dieser Artikel vertieft die zweite der drei Routinen von dort: die Eindeutigkeitsprüfung.

Inhalt

Duplikate finden: die Grundform

Wer Duplikate finden will, beginnt fast immer hier: Man gruppiert die Tabelle nach dem fachlichen Schlüssel und behält nur die Gruppen, die mehr als eine Zeile enthalten. Für die Regel „jede Kundennummer darf höchstens einmal vorkommen“ sieht das so aus:

  1: SELECT
  2:    customer_id
  3: FROM
  4:    staging.customer
  5: GROUP BY
  6:    customer_id
  7: HAVING
  8:    count(*) > 1;          -- 1

Das Ergebnis ist die Kundennummer 1 — sie steht zweimal in der Tabelle. Wichtig ist, was hier nicht zurückkommt: nicht die doppelten Zeilen, sondern die doppelten Schlüssel. Welche konkreten Datensätze betroffen sind, ist eine zweite Frage — dazu der Abschnitt Jedes Vorkommen protokollieren.

Der fachliche Schlüssel ist dabei genau die Spalte, die den Datensatz identifiziert — dieselbe, die am Ziel den UNIQUE-Constraint trägt und in der Fehlertabelle als id1_column landet. Das ist die Dimension Eindeutigkeit: Kommt jeder Schlüssel nur so oft vor, wie er darf?

Von Eindeutigkeit zu Kardinalität: höchstens n Vorkommen

Die > 1 ist kein Naturgesetz, sondern ein Parameter. „Eindeutig“ heißt „höchstens einmal“ — aber manche Regeln erlauben einen Schlüssel mehrfach, nur nicht beliebig oft. Ein Gutschein-Code darf vielleicht höchstens dreimal eingelöst werden:

  1: SELECT
  2:    promo_code
  3: FROM
  4:    staging.promo_redemption
  5: GROUP BY
  6:    promo_code
  7: HAVING
  8:    count(*) > 3;          -- SAVE10  (4x eingeloest, erlaubt sind 3)

Aus „Eindeutigkeit“ wird damit eine maximale Kardinalität: ein Schlüssel darf höchstens n-mal auftreten. Die 3 ist nur ein anderer Wert für n — > 1 ist der Spezialfall echter Eindeutigkeit. Im generischen Gerüst steckt dieses n in einer Konfigurationsspalte (max_occurrence), nicht im Code. So bildet eine Routine sowohl die harte Eindeutigkeit als auch weichere Mengen-Regeln ab.

Zusammengesetzte Schlüssel

Selten ist der fachliche Schlüssel eine einzige Spalte. Eine Bestellposition ist erst durch order_id und product_id eindeutig; dieselbe Bestellung darf dasselbe Produkt nicht zweimal als Position führen. Die Prüfung gruppiert dann über alle Spalten des Schlüssels:

  1: SELECT
  2:     order_id
  3:    ,product_id
  4: FROM
  5:    staging.order_item
  6: GROUP BY
  7:     order_id
  8:    ,product_id
  9: HAVING
 10:    count(*) > 1;          -- 1000 | A-1

Entscheidend ist die Vollständigkeit der Spaltenliste, nicht ihre Reihenfolge: GROUP BY order_id, product_id und GROUP BY product_id, order_id liefern dieselben Gruppen. Wer dagegen eine Schlüsselspalte vergisst, prüft einen anderen, gröberen Schlüssel — und meldet entweder zu viele oder zu wenige Duplikate. Der häufigste Fehler ist, nur über order_id zu gruppieren und sich dann zu wundern, dass jede mehrzeilige Bestellung als „Duplikat“ auftaucht.

Die NULL-Falle bei UNIQUE: SQL Server vs. Postgres

Bis hierher war count(*) harmlos. Sobald die geprüfte Spalte aber NULL enthalten darf, wird es heikel. Nehmen wir eine optionale, aber eindeutige Steuernummer tax_id:

  1: SELECT
  2:     tax_id
  3:    ,count(*) AS n
  4: FROM
  5:    staging.customer
  6: GROUP BY
  7:    tax_id
  8: HAVING
  9:    count(*) > 1;
 10: -- DE0002 | 2     echtes Duplikat (zwei Kunden, gleiche Steuernummer)
 11: -- NULL   | 2     zwei FEHLENDE Steuernummern — in EINER Gruppe

GROUP BY wirft alle NULL in eine Gruppe — nicht, weil zwei NULL gleich wären (NULL = NULL ergibt unbekannt), sondern weil das Gruppieren fehlende Werte als zusammengehörig behandelt. Zwei Kunden ohne Steuernummer erscheinen damit als „Duplikat“, obwohl schlicht beiden der Wert fehlt. Das ist kein Eindeutigkeits-, sondern ein Vollständigkeitsproblem — und gehört in eine eigene Prüfung (siehe Daten mit SQL validieren).

Jetzt kommt der Teil, der bei einer Migration weh tut. Was ein UNIQUE-Constraint mit NULL macht, ist zwischen den Engines nicht gleich:

  1: -- Postgres-Default: mehrere NULL sind in einer UNIQUE-Spalte erlaubt
  2: CREATE TABLE t_default (tax_id text UNIQUE);
  3: INSERT INTO t_default VALUES (NULL), (NULL);   -- OK: zwei NULL nebeneinander
  4:
  5: -- Ab Postgres 15: NULL wie einen normalen Wert behandeln
  6: CREATE TABLE t_strict (tax_id text UNIQUE NULLS NOT DISTINCT);
  7: INSERT INTO t_strict VALUES (NULL), (NULL);    -- FEHLER: zweites NULL kollidiert

Postgres betrachtet zwei NULL standardmäßig als verschieden (NULLS DISTINCT) — beliebig viele fehlende Werte sind in einer UNIQUE-Spalte erlaubt. SQL Server macht das Gegenteil: Dort gilt in einem UNIQUE-Constraint genau ein NULL als zulässig, das zweite wird als Duplikat abgewiesen. SQL Servers UNIQUE-Constraint verhält sich also wie Postgres‘ NULLS NOT DISTINCT (ab Version 15) — nicht wie der Postgres-Default.

Die Migrationsfolge ist konkret: Eine Postgres-Tabelle mit drei legal nebeneinander stehenden NULL in einer UNIQUE-Spalte lässt sich nicht 1:1 nach SQL Server heben — dort kollidieren das zweite und dritte NULL. Wer das vorab prüfen will, muss entscheiden, welche Semantik er nachbildet. Für ein Postgres-Ziel mit Default-UNIQUE schließt man fehlende Schlüssel aus der Prüfung aus:

  1: SELECT
  2:    tax_id
  3: FROM
  4:    staging.customer
  5: WHERE
  6:    tax_id IS NOT NULL
  7: GROUP BY
  8:    tax_id
  9: HAVING
 10:    count(*) > 1;          -- nur DE0002

Für ein SQL-Server-Ziel (oder ein Postgres-Ziel mit NULLS NOT DISTINCT) lässt man das WHERE weg — dann ist das zweite NULL zu Recht ein Befund. Am Rande: count(*) zählt alle Zeilen der Gruppe, count(tax_id) würde die NULL ignorieren und käme für die NULL-Gruppe auf 0. Für die Kardinalitätsprüfung ist count(*) der richtige Zähler — man will ja wissen, wie viele Zeilen den Schlüssel teilen.

Jedes Vorkommen protokollieren, nicht nur eines

Jetzt zur offenen Frage aus der Grundform: Sie liefert nur die doppelten Schlüssel — für die Fehlertabelle braucht man jede betroffene Zeile, sonst fehlt in dq.error die Hälfte der Sätze. Der direkte Weg ist ein Rück-Join der doppelten Schlüssel auf die Tabelle. Als eigener, benannter Schritt liest sich das am klarsten als Common Table Expression (CTE) — die doppelten Schlüssel bekommen einen Namen, der danach zurück-gejoint wird:

  1: WITH
  2: CTE_duplicate_keys AS
  3: (
  4:    SELECT
  5:       customer_id
  6:    FROM
  7:       staging.customer
  8:    GROUP BY
  9:       customer_id
 10:    HAVING
 11:       count(*) > 1
 12: )
 13: SELECT
 14:    T01.*
 15: FROM
 16:    staging.customer T01
 17:    INNER JOIN CTE_duplicate_keys T02
 18:    ON
 19:      T02.customer_id = T01.customer_id;

Eleganter — und ohne den expliziten Rück-Join — geht es mit einer Fensterfunktion. count(*) OVER (PARTITION BY …) hängt jeder Zeile die Gruppengröße an, ohne zu aggregieren:

  1: SELECT
  2:     customer_id
  3:    ,email
  4:    ,count(*) OVER (PARTITION BY customer_id) AS n_total
  5: FROM
  6:    staging.customer;
  7: -- jede Zeile behaelt ihre Identitaet, n_total = Groesse ihrer Schluessel-Gruppe

Alles mit n_total > 1 ist ein Duplikat-Vorkommen. Braucht man zusätzlich eine „erste“ Zeile (etwa um sie zu behalten und die übrigen zu markieren), liefert ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY …) die laufende Nummer innerhalb der Gruppe. Für die reine Protokollierung zählt aber jeder betroffene Satz gleich — n_total > 1 reicht, und jede dieser Zeilen wird zu einem Eintrag in dq.error.

Performance: hier hilft der Index

Anders als die freien Prädikate der WHERE-Routine ist die Eindeutigkeitsprüfung indexfreundlich. Ein passender Index auf dem Schlüssel kann dem Optimizer erlauben, statt eines HashAggregate (die ganze Tabelle wandert in eine Hash-Tabelle) einen GroupAggregate über bereits sortierte Daten zu wählen, gespeist aus einem Index-Scan. Erzwingen lässt sich das nicht — der Planer entscheidet nach Kosten und greift bei kleineren Mengen oft trotzdem zum HashAggregate. Bei großen Tabellen kann der Index aber den Ausschlag geben.

Trotzdem bleibt es im Kern ein Durchlauf über alle Zeilen — Duplikate erkennt man nicht, ohne jeden Wert einmal gesehen zu haben. Der gleiche Hebel wie bei jeder Staging-Prüfung gilt deshalb auch hier: Man begrenzt die Prüfung mit einem Lade-Marker (load_idbatch_id) auf den aktuell geladenen Batch. So muss nicht jedes Mal der gesamte Bestand neu gruppiert werden. Und wenn am Ziel ohnehin ein UNIQUE-Index auf dem Schlüssel liegt, ist die Vorab-Prüfung exakt die Arbeit, die die Datenbank beim Laden sowieso leisten würde — nur eben in der Quelle, wo ein Verstoß meldbar statt fatal ist.

Generisch über die Config-Routine

Hartkodiert ist auch diese Prüfung nicht praktikabel. Generisch wird sie über eine Konfigurationszeile, aus der der Runner des Framework-Artikels zur Laufzeit das GROUP BY … HAVING count(*) > max_occurrence baut und die Treffer auf die Tabelle zurück-joint:

  1: INSERT INTO dq.check_rule
  2:    (check_type, schema_name, table_name, id1_column, check_column, max_occurrence, severity, message)
  3: VALUES
  4:     ('unique', 'staging', 'customer',   'customer_id', 'customer_id', 1, 'E', 'customer_id nicht eindeutig')
  5:    ,('unique', 'staging', 'promo_redemption', 'promo_code', 'promo_code', 3, 'W', 'Gutschein zu oft eingeloest');

check_type = 'unique' wählt im Runner den Eindeutigkeits-Zweig; max_occurrence liefert das n. Schema-, Tabellen- und Spaltennamen setzt der Runner über format() mit %I ein (injection-sicher) — ein Eindeutigkeits-Check braucht im Gegensatz zur WHERE-Routine kein frei konfiguriertes Roh-SQL und ist damit auch ohne die dortige Vertrauensgrenze auskommend. Zusammengesetzte Schlüssel deckt der Runner über die weiteren Schlüsselspalten (id2_columnid3_column) ab. Die vollständige Runner-Mechanik steht im Framework-Artikel.

Brücke: dasselbe in SQL Server

Das Muster ist nicht Postgres-spezifisch. In SQL Server übernimmt sp_executesql die Rolle von EXECUTE format(), Bezeichner schützt QUOTENAME() statt %IGROUP BY … HAVING count(*) > 1, der Rück-Join und ROW_NUMBER() OVER (PARTITION BY …) sind wortgleich — Fensterfunktionen gehören in beiden Engines zum Standard.

Der einzige echte Unterschied ist die NULL-Semantik aus dem Abschnitt oben: SQL Servers UNIQUE-Constraint erlaubt genau ein NULL. Wer dort mehrere fehlende Werte zulassen will, braucht einen gefilterten eindeutigen Index (CREATE UNIQUE INDEX … WHERE tax_id IS NOT NULL) — das Gegenstück zu Postgres‘ Default-Verhalten. Struktur, Fehlertabelle und Schweregrad-Gate bleiben identisch; nur die Dialekt-Werkzeuge und diese eine Semantik-Differenz unterscheiden sich.

FAQ

Wie finde ich Duplikate über mehrere Spalten?

Mit einem Mehrspalten-GROUP BY: alle Spalten des fachlichen Schlüssels in die GROUP BY-Liste, dann HAVING count(*) > 1. Die Reihenfolge der Spalten ist egal, ihre Vollständigkeit nicht — fehlt eine Schlüsselspalte, prüft man einen gröberen Schlüssel und bekommt falsche Treffer. Für (order_id, product_id) gruppiert man also über beide Spalten, nicht nur über order_id.

Warum erlaubt Postgres mehrere NULL in einer UNIQUE-Spalte?

Weil Postgres standardmäßig zwei NULL als verschieden betrachtet (NULLS DISTINCT): Ein fehlender Wert ist nicht gleich einem anderen fehlenden Wert. Ab Postgres 15 kehrt UNIQUE NULLS NOT DISTINCT das um und erlaubt nur noch ein NULL — genau das Verhalten, das SQL Server von Haus aus zeigt.

Wie protokolliere ich alle Duplikate, nicht nur das erste?

Die Grundform liefert nur die doppelten Schlüssel. Um jede betroffene Zeile zu bekommen, joint man die Schlüssel-Liste zurück auf die Tabelle oder nutzt count(*) OVER (PARTITION BY schlüssel) und filtert auf > 1. So landet jedes Vorkommen in der Fehlertabelle — wichtig, weil sonst die Hälfte der Duplikat-Zeilen unprotokolliert bleibt.

Duplikate finden oder löschen — wo ist der Unterschied?

Dieser Artikel findet nur: Er meldet Duplikate transparent in die Fehlertabelle, verändert die Daten nicht. Das Löschen (etwa per DELETE mit ROW_NUMBER()) ist Bereinigung — eine bewusst getrennte Folge-Ebene, die im Cluster nicht behandelt wird. Erst finden und klassifizieren, dann entscheiden, was mit den Treffern geschieht.

Geht das in SQL Server genauso?

Im Kern ja. GROUP BY … HAVING count(*) > 1, der Rück-Join und ROW_NUMBER() sind identisch, sp_executesql ersetzt EXECUTE format()QUOTENAME() ersetzt %I. Der eine Unterschied: SQL Servers UNIQUE erlaubt genau ein NULL; mehrere fehlende Werte braucht einen gefilterten Index WHERE spalte IS NOT NULL.

Verwandte Artikel

  • Datenqualität mit SQL prüfen — das Framework, aus dem dieser Artikel die Eindeutigkeitsroutine vertieft: gemeinsame Fehlertabelle, Schweregrad-Gate, der dynamische Runner.
  • Daten mit SQL validieren — die Schwester-Routine zur Validität und Vollständigkeit, inklusive der NULL-Falle in der WHERE-Klausel.
  • Postgres-Tabellen-Konventionen — wie UNIQUE– und Schlüssel-Constraints am Ziel-Schema sauber modelliert werden.
  • Referenzielle Integrität (verwaiste Datensätze finden) ist die dritte Routine der Serie und der Konzept-Rahmen zu den Datenqualitäts-Dimensionen — die zugehörigen Artikel folgen.