Eine Wertebereichsprüfung, die grün durchläuft, ist kein Beweis für saubere Daten. Wer WHERE alter < 0 OR alter > 120 schreibt, um unplausible Altersangaben zu finden, übersieht stillschweigend jede Zeile, in der alter gar keinen Wert hat — denn ein Vergleich mit NULL ergibt in SQL weder wahr noch falsch, sondern unbekannt. Genau dieser fehlende Pflichtwert kippt später den Load ins strikt modellierte Ziel. Daten mit SQL validieren heißt deshalb: nicht nur Wertebereiche prüfen, sondern Validität und Vollständigkeit sauber trennen — und die drei-wertige Logik kennen, bevor sie Zeilen verschluckt.
Das Wichtigste vorab:
- Eine WHERE-Klausel, die schlechte Zeilen beschreibt, ist die mächtigste Einzelprüfung — und deckt Validität (Wertebereiche, Formate, Plausibilität) und Vollständigkeit (Pflichtfelder) ab.
- Die NULL-Falle:
NULL-Werte fallen aus jeder Bedingung und ihrer Negation heraus — ein fehlender Wert ist weder „gut“ noch „schlecht“, er verschwindet aus beiden Mengen. - Validität (ist der Wert erlaubt?) und Vollständigkeit (ist überhaupt ein Wert da?) sind zwei verschiedene Dimensionen — und gehören in zwei getrennte Prüfungen, nicht in eine.
- Hartkodiert ist keine dieser Prüfungen praktikabel: Die Regeln stehen in einer Konfigurationstabelle, aus der der Runner des Framework-Artikels das SQL generisch zusammenbaut.
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 erste der drei Routinen von dort: die WHERE-Klausel.
Inhalt
- Die WHERE-Klausel als Validitätsprüfung
- Die NULL-Falle: warum drei-wertige Logik Zeilen verschluckt
- Validität ist nicht Vollständigkeit
- Formate, Längen, Muster
- Regeln über mehrere Felder
- Performance: der Full-Scan und seine Grenzen
- Generisch über die Config-Routine
- Brücke: dasselbe in SQL Server
- FAQ
- Verwandte Artikel
Die WHERE-Klausel als Validitätsprüfung
Wer Daten mit SQL validieren will, beginnt fast immer hier. Das Prinzip ist denkbar einfach: Man formuliert eine Bedingung, die schlechte Zeilen beschreibt, hängt sie an die Tabelle — und alles, was die Bedingung trifft, ist ein Befund. Für die Regel „Alter muss zwischen 0 und 120 liegen“ sieht das so aus:
1: INSERT INTO dq.error (schema_name, table_name, id1_column, id1_value,
2: error_column, error_value, severity, message)
3: SELECT
4: 'staging'
5: ,'customer'
6: ,'customer_id'
7: ,T01.customer_id::text
8: ,'age'
9: ,T01.age::text
10: ,'E'
11: ,'Alter ausserhalb 0..120'
12: FROM
13: staging.customer T01
14: WHERE
15: T01.age < 0 OR T01.age > 120;
Das Prädikat in Zeile 15 beschreibt, was „ungültig“ heißt. Diese eine Form deckt eine ganze Familie von Validitätsregeln ab: Wertebereiche (age < 0 OR age > 120), Plausibilität (order_date > current_date), feste Wertelisten (country_code NOT IN ('DE', 'AT', 'CH')). Das ist die Dimension Validität — entsprechen die Werte den erlaubten Regeln?
Und genau hier lauert der Fehler, den fast jeder einmal macht.
Die NULL-Falle: warum drei-wertige Logik Zeilen verschluckt
SQL kennt nicht zwei Wahrheitswerte, sondern drei: wahr, falsch und unbekannt. Jeder Vergleich mit NULL ergibt unbekannt — und WHERE lässt nur Zeilen durch, deren Bedingung wahr ist. Eine Zeile mit age IS NULL trifft age < 0 OR age > 120 also nicht: Der Datensatz mit dem fehlenden Alter wird von der Prüfung nicht als Befund erkannt.
Schlimmer wird es, wenn man glaubt, mit der Negation auf der sicheren Seite zu sein. „Gut“ und „schlecht“ zusammen müssten doch alle Zeilen ergeben:
1: -- "gut": Alter im erlaubten Bereich
2: SELECT customer_id FROM staging.customer
3: WHERE
4: age BETWEEN 0 AND 120; -- 1, 5
5:
6: -- "schlecht": einfach negiert
7: SELECT customer_id FROM staging.customer
8: WHERE
9: NOT (age BETWEEN 0 AND 120); -- 2, 3
Beide Abfragen zusammen liefern die Kunden 1, 2, 3, 5 — Kunde 4 mit age IS NULL taucht in keiner der beiden auf. NOT (unbekannt) ist wieder unbekannt, nicht wahr. Der fehlende Wert fällt aus der „gut“-Menge und aus der „schlecht“-Menge heraus und verschwindet lautlos. Eine Validierung, die sich auf „alles, was nicht gültig ist“ verlässt, lässt fehlende Pflichtwerte ungeprüft durchrutschen.
Die Lehre: Wer Daten mit SQL validieren will, muss NULL explizit behandeln — IS NULL und IS NOT NULL sind keine Kür, sondern Pflicht. Niemals davon ausgehen, dass eine Negation den fehlenden Wert miterwischt.
Validität ist nicht Vollständigkeit
Die saubere Lösung ist, die zwei Fragen getrennt zu stellen. „Fehlt der Wert?“ ist Vollständigkeit, „ist der vorhandene Wert erlaubt?“ ist Validität — oft mit unterschiedlichem Schweregrad:
1: -- Vollstaendigkeit: Pflichtfeld fehlt (eigener Befund)
2: INSERT INTO dq.error (...)
3: SELECT ..., 'age', NULL, 'E', 'Alter fehlt (Pflichtfeld)'
4: FROM staging.customer T01
5: WHERE
6: T01.age IS NULL;
7:
8: -- Validitaet: Bereich, ausdruecklich nur fuer vorhandene Werte
9: INSERT INTO dq.error (...)
10: SELECT ..., 'age', T01.age::text, 'E', 'Alter ausserhalb 0..120'
11: FROM staging.customer T01
12: WHERE
13: T01.age IS NOT NULL
14: AND (T01.age < 0 OR T01.age > 120);
Jetzt bekommt Kunde 4 seinen eigenen Eintrag „Alter fehlt“, und der Wertebereich wird nur dort geprüft, wo ein Wert existiert. Das IS NOT NULL in Zeile 13 ist technisch redundant — der Vergleich würde NULL ohnehin ausschließen —, macht die Absicht aber sichtbar: Diese Prüfung ist für die Validität zuständig, nicht für die Vollständigkeit. Ob der fehlende Wert ein Fehler (E) oder nur eine Warnung (W) ist, hängt davon ab, ob das Feld am Ziel NOT NULL ist.
Formate, Längen, Muster
Dieselbe WHERE-Routine deckt Format- und Musterprüfungen ab. In Postgres erledigt das der Regex-Operator ~ (passt) bzw. !~ (passt nicht); jede Prüfung schließt NULL wieder bewusst aus:
1: -- PLZ muss 5-stellig numerisch sein
2: WHERE
3: T01.zip IS NOT NULL
4: AND T01.zip !~ '^[0-9]{5}$';
5:
6: -- Geburtsdatum darf nicht in der Zukunft liegen
7: WHERE
8: T01.birth_date > current_date;
Damit lassen sich Längen (length(zip) <> 5), einfache Muster (E-Mail enthält ein @), Wertelisten und Datums-Plausibilität abbilden. Für einfache syntaktische Prüfungen taugt die WHERE-Klausel, für echte Spezifikationen nicht — eine vollständige E-Mail-Validierung nach RFC gehört nicht hinein. Für Zahlen-aus-Text ist die feldweise Typkonvertierung der robustere Weg; sie ist die feinkörnige Validitätsprüfung, die der Cluster um TRY_CONVERT und die Grundlagen der Typ-Konvertierung im Detail behandelt.
Regeln über mehrere Felder
Eine WHERE-Klausel kann auch Beziehungen zwischen Spalten einer Zeile prüfen — „Rabatt nur, wenn Status aktiv“, „Enddatum nicht vor Startdatum“:
1: WHERE
2: T01.discount > 0
3: AND T01.status <> 'active';
Auch hier gilt die NULL-Regel: Ist status NULL, ist status <> 'active' unbekannt und die Zeile fällt durch. Feldübergreifende Geschäftsregeln lassen sich teils so ausdrücken, teils nicht — sobald sie Aggregate, Zeitreihen oder externe Referenzen brauchen, ist die Grenze der Einzeltabellen-Prüfung erreicht. Verwaiste Fremdschlüssel etwa sind ein eigenes Thema (referenzielle Integrität).
Performance: der Full-Scan und seine Grenzen
Ehrlich bleiben: Jede dieser Prüfungen ist im Kern ein Full-Table-Scan. Ein frei formuliertes Prädikat wie age < 0 OR age > 120 kann keinen normalen Index nutzen — die Datenbank muss jede Zeile ansehen. Wie schwer das wiegt, hängt aber weniger an der Prüfung selbst als an der Datenmenge, gegen die sie läuft.
Entscheidend ist die Art des Ladevorgangs. Beim initialen Load wandert der gesamte Bestand durch das Staging — Millionen Zeilen, jede Regel ein voller Scan: Das ist der Fall, in dem Performance wirklich zählt. Läuft der ETL-Prozess dagegen als Delta- bzw. inkrementeller Load, kommen pro Lauf nur die geänderten Datensätze an. Dann prüft man eine Handvoll bis einige tausend Zeilen statt der ganzen Tabelle — der Full-Scan über diese kleine Menge ist vernachlässigbar, und im laufenden Betrieb erübrigt sich die Performance-Frage praktisch von selbst.
Der Hebel ist also, die Prüfung je Batch auf die frisch geladene Menge zu begrenzen, statt jedes Mal die komplette Tabelle zu scannen. Eine Staging-Tabelle darf dabei durchaus die Daten mehrerer Batches enthalten — ein Lade-Marker (etwa eine load_id– oder batch_id-Spalte) grenzt jede Prüfung auf den aktuellen Batch ein, sodass bereits verarbeitete Zeilen nicht erneut gescannt werden. Damit bleibt allein der initiale Load ein echter Engpass; dort hilft zusätzlich ein funktionaler oder partieller Index, wenn dieselbe Validitätsregel oft und selektiv läuft (CREATE INDEX … ON staging.customer (age) WHERE age < 0 OR age > 120). Für ein beliebiges Prädikat gibt es aber keine Index-Garantie — das gehört zur Aufwandsschätzung dazu.
Generisch über die Config-Routine
Hartkodiert ist keine dieser Prüfungen brauchbar. Generisch werden sie, indem das Prädikat nicht im Code, sondern in einer Konfigurationszeile steht — eine Zeile je Regel, die der Runner aus dem Framework-Artikel zur Laufzeit in ein INSERT INTO dq.error … SELECT … WHERE übersetzt:
1: INSERT INTO dq.check_rule
2: (check_type, schema_name, table_name, id1_column, check_column, where_clause, severity, message)
3: VALUES
4: ('constraint', 'staging', 'customer', 'customer_id', 'age', 'age IS NULL' , 'E', 'Alter fehlt (Pflichtfeld)')
5: ,('constraint', 'staging', 'customer', 'customer_id', 'age', 'age IS NOT NULL AND (age < 0 OR age > 120)', 'E', 'Alter ausserhalb 0..120' );
Der Runner setzt Schema- und Spaltennamen über format() mit %I ein (injection-sicher), das Prädikat aus where_clause dagegen über %s — als unverändertes SQL. Das muss so sein, denn where_clause ist ein SQL-Ausdruck, kein Wert. Damit ist die dq.check_rule-Tabelle die Vertrauensgrenze: Wer dort schreiben darf, kann beliebiges SQL ausführen lassen — unkritisch, solange die Konfiguration administrativ gepflegt wird und nie aus Benutzereingaben stammt. 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 %I. Eine Stolperstelle ist anders: Für „ist das eine Zahl?“ greift man gern zu ISNUMERIC() — das aber meldet auch Währungs- und Exponentialschreibweisen als numerisch:
1: SELECT ISNUMERIC('$1,000'); -- 1 (faelschlich "numerisch")
2: SELECT ISNUMERIC('1e4'); -- 1 (faelschlich "numerisch")
3: SELECT TRY_CONVERT(int, '$1,000'); -- NULL (sauber abgewiesen)
4: SELECT TRY_CONVERT(int, '1e4'); -- NULL
TRY_CONVERT ist der verlässliche Weg: Es liefert NULL, wenn sich der Wert nicht konvertieren lässt, statt ihn fälschlich durchzuwinken. Struktur, Fehlertabelle, Schweregrad-Gate und die NULL-Falle bleiben in SQL Server identisch — nur die Dialekt-Werkzeuge unterscheiden sich.
FAQ
Weil jeder Vergleich mit NULL in SQL nicht falsch, sondern unbekannt ergibt — und WHERE nur Zeilen durchlässt, deren Bedingung wahr ist. Eine Zeile mit fehlendem Wert trifft weder die Bedingung noch ihre Negation. Fehlende Werte müssen mit IS NULL als eigene Prüfung abgefangen werden.
Vollständigkeit fragt: Ist überhaupt ein Wert da? (age IS NULL). Validität fragt: Ist der vorhandene Wert erlaubt? (age < 0 OR age > 120). Das sind zwei Datenqualitäts-Dimensionen und zwei getrennte Prüfungen — oft mit unterschiedlichem Schweregrad: fehlender Pflichtwert als Fehler, Format-Abweichung vielleicht nur als Warnung.
Nur eingeschränkt. Ein freies Prädikat nutzt keinen normalen Index — die Prüfung ist ein Full-Scan. Entscheidend ist aber die Datenmenge: Beim Delta- bzw. inkrementellen Load prüft man nur die wenigen frisch geladenen Zeilen, und die Performance-Frage stellt sich kaum. Spürbar wird sie vor allem beim initialen Load über den Gesamtbestand — dort hilft ein partieller Index (CREATE INDEX … WHERE <prädikat>), und generell begrenzt ein Lade-Marker die Prüfung auf den aktuellen Batch, statt jedes Mal die ganze Tabelle zu scannen.
Sichere Typkonvertierung ist die feldweise Validitätsprüfung: „Lässt sich dieser Text als Datum/Zahl lesen?“ TRY_CONVERT (SQL Server) bzw. ein Cast mit Fehlerbehandlung (Postgres) liefert NULL statt eines Laufzeitfehlers, wenn der Wert nicht passt — robuster als eine Regex-Formatprüfung für Zahlen und Datumswerte.
Ja. sp_executesql ersetzt EXECUTE format(), QUOTENAME() ersetzt %I, und statt der ISNUMERIC-Falle nimmt man TRY_CONVERT. Die drei-wertige Logik und damit die NULL-Falle gelten in SQL Server genauso.
Verwandte Artikel
- Datenqualität mit SQL prüfen — das Framework, aus dem dieser Artikel die WHERE-Routine vertieft: gemeinsame Fehlertabelle, Schweregrad-Gate, der dynamische Runner.
- Datenqualität // Grundlagen der Typ-Konvertierung mit T-SQL — die feldweise Validitätsprüfung beim Konvertieren.
- TRY_CONVERT nach date, datetime, datetime2, time — sichere Konvertierung als konkrete Validitätsprüfung.
- Duplikate finden mit SQL — die Schwester-Routine zur Eindeutigkeit: maximale Kardinalität, zusammengesetzte Schlüssel und die NULL-Falle bei
UNIQUE. Referenzielle Integrität (verwaiste Datensätze) ist die dritte Routine der Serie — der zugehörige Artikel folgt.