Drei Schweregrade statt pass/fail — und warum der Schweregrad entscheidet, wo die Regel steht

Die meisten Datenqualitäts-Systeme sterben nicht an schlechten Prüfungen, sondern an einem fehlenden Feld im Regel-Modell: dem Schweregrad. Ein fehlendes Länderkürzel in drei von 80.000 Zeilen blockiert den Nacht-Load, jemand schaltet die Prüfung „vorübergehend“ ab, und ab da läuft alles ungeprüft. Wer Datenqualität mit Schweregraden statt mit binärem pass/fail steuert, entkommt dieser Falle. Und der Schweregrad kann noch mehr: Er entscheidet, ob eine Regel als Constraint ins Ziel-Schema gehört oder in die Pipeline.

Das Wichtigste vorab:

  • Binäres pass/fail endet in zwei Sackgassen: Entweder blockiert eine Kleinigkeit den ganzen Load, oder alles läuft durch und niemand liest die Befunde.
  • Drei Stufen reichen: Error blockiert, Warnung ist sichtbar ohne zu blockieren, Information dokumentiert nur.
  • Der Schweregrad ist ein Routing-Entscheid: Nur eine Error-Regel ist Kandidat für ein CHECK– oder UNIQUE-Constraint im Ziel-Schema — Warnung und Information bleiben in der Pipeline.
  • Hochstufen hat einen Preis: Wird aus einer Warnung ein Error, muss der Bestand plötzlich sauber sein. NOT VALID und VALIDATE CONSTRAINT machen den Übergang kontrollierbar.
  • Die Grenzen sind klar benennbar: Zeilenübergreifende, tabellenübergreifende und zeitliche Regeln kann ein Constraint nicht. Die gehören dauerhaft in die Pipeline.

Voraussetzung: Postgres als Beispiel-Engine und das Framework aus dem Sub-Hub Datenqualität mit SQL prüfen mit der gemeinsamen Fehlertabelle, der Regel-Config dq.check_rule und dem Quality Gate. Das Prinzip selbst ist engine-neutral und gilt genauso für SQL Server oder jedes andere System mit Constraints.

Inhalt

Warum binär scheitert

Ein binäres Prüfsystem kennt pro Datensatz nur eine Frage: bestanden oder durchgefallen. Das klingt sauber und führt in der Praxis in eine von zwei Sackgassen.

Sackgasse eins: Alles Durchgefallene blockiert. Dann stoppt die fehlende E-Mail-Adresse denselben Load wie die doppelte Kundennummer. Der Prozess steht, das Tagesgeschäft wartet, und die Person im Bereitschaftsdienst trifft nachts um drei eine Entscheidung, die eigentlich in ein Regel-Review gehört: Sie schaltet die Prüfung ab. Nicht aus Nachlässigkeit, sondern weil das System ihr keine andere Abstufung anbietet.

Sackgasse zwei: Nichts blockiert, alles wird nur protokolliert. Dann füllt sich eine Befund-Tabelle, die anfangs noch jemand liest. Nach ein paar Wochen stehen dort tausende Einträge, wichtige neben belanglosen, und weil das System selbst nicht sagt, welche davon Handlungsdruck haben, tut es irgendwann niemand mehr. Die Prüfung läuft weiter und ist trotzdem tot.

Beide Reflexe sind verständlich. Das Problem ist nicht, dass Menschen falsch mit dem System umgehen, sondern dass ein binäres Modell die eine Information unterschlägt, die den Umgang steuern müsste: Wie schlimm ist dieser Befund?

Die drei Stufen

Drei Schweregrade beantworten genau diese Frage, und mehr als drei braucht es selten. Das Muster ist nicht neu: Syslog-Level und Linter kennen es seit Jahrzehnten. Interessant wird es dadurch, was jede Stufe im ETL-Prozess konkret auslöst:

  • Error (E) blockiert. Ein Satz mit einem Error-Befund passiert das Quality Gate nicht und erreicht die Zielschicht nicht. Error ist die richtige Stufe für alles, woran der Load am Ziel ohnehin scheitern würde: fehlende Pflichtwerte, doppelte Schlüssel, unbekannte Referenzen.
  • Warnung (W) ist sichtbar, blockiert aber nicht. Der Satz fließt weiter, der Befund bleibt protokolliert. Das deckt den größten Teil der realen Datenprobleme ab: fachlich Auffälliges, Unschönes, Prüfwürdiges, das den Prozess nicht aufhalten darf.
  • Information (I) dokumentiert nur. Kein Handlungsdruck, keine Blockade, reine Kenntnisnahme, etwa bei einer fehlenden Telefonnummer, deren Häufigkeit man beobachten will.

Im Regel-Modell des Frameworks ist der Schweregrad (in manchen Regel-Modellen heißt das Feld „Kritikalität“) eine Spalte der Konfigurations-Tabelle, mit striktem Default und einem CHECK auf sich selbst:

  1: ,severity   char(1) NOT NULL DEFAULT 'E'
  2: ,CONSTRAINT ck_check_rule_severity  CHECK (severity IN ('E', 'W', 'I'))

Der Default E ist eine bewusste Entscheidung: Wer eine Regel schreibt, meint sie zunächst ernst. Herabstufen auf Warnung ist ein aktiver, dokumentierter Schritt. Der umgekehrte Default würde neue Regeln stillschweigend zu Beobachtern machen. Fünf Regeln zeigen die drei Stufen im Format der Config-Tabelle:

  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', 'country_code', 'country_code IS NULL', 'E', 'Laendercode ist Pflichtfeld im Zielschema')
  5:    ,('constraint', 'staging',   'customer', 'customer_id', 'age',          'age IS NULL',          'E', 'Alter ist Pflichtfeld im Zielschema')
  6:    ,('constraint', 'staging',   'customer', 'customer_id', 'age',          'age < 18',             'W', 'Kunde unter 18 - fachlich pruefen')
  7:    ,('constraint', 'staging',   'customer', 'customer_id', 'email',        'email IS NULL',        'W', 'E-Mail fehlt - fachlich pruefen')
  8:    ,('constraint', 'staging',   'customer', 'customer_id', 'phone',        'phone IS NULL',        'I', 'Telefonnummer nicht hinterlegt - nur zur Kenntnis');

Die Info-Regel zielt bewusst auf ein Feld, das fachlich optional ist: Eine fehlende Telefonnummer ist kein Mangel, aber ein Wert, dessen Quote man kennen möchte. Auf age liegen dagegen zwei Regeln mit verschiedenen Stufen: Fehlt das Alter ganz, ist das ein Fehler, weil das Ziel die Spalte als Pflichtfeld behandelt. Ist es nur auffällig klein, ist das eine Warnung. Die Stufen-Wahl ist eine fachliche Aussage, keine technische.

Was die Stufen praktisch bedeuten, zeigt ein Lauf gegen sieben Demo-Zeilen (eine saubere, je eine pro Schweregrad und drei Kombinationen). Die Staging-Tabelle aus dem Framework ist dafür um die optionale Spalte phone erweitert:

  1: INSERT INTO staging.customer (customer_id, country_code, email, age, phone) VALUES
  2:     (1, 'DE', 'a@example.com', 44,   '+49 30 1234567')   -- sauber
  3:    ,(2, NULL, 'b@example.com', 33,   '+49 40 2345678')   -- E: Laendercode fehlt
  4:    ,(3, 'AT', 'c@example.com', 17,   '+43 1 3456789')    -- W: unter 18
  5:    ,(4, 'CH', 'd@example.com', 52,   NULL)               -- I: Telefonnummer fehlt
  6:    ,(5, NULL, 'e@example.com', NULL, '+41 44 5678901')   -- E+E: Laendercode und Alter fehlen
  7:    ,(6, NULL, 'f@example.com', 16,   '+49 89 6789012')   -- E+W: Laendercode fehlt, unter 18
  8:    ,(7, NULL, NULL,            NULL, NULL);              -- E+E+W+I: alles auf einmal
  9: 
 10: SELECT dq.fn_run_checks('staging', 'customer') AS total_findings;  -- 11

Der Runner schreibt die Schweregrad-Zähler in die Quelltabelle zurück, pro Datensatz einen je Stufe:

customer_idcountry_codeemailagephonesys_errorsys_warningsys_info
1DEa@example.com44+49 30 1234567000
2b@example.com33+49 40 2345678100
3ATc@example.com17+43 1 3456789010
4CHd@example.com52001
5e@example.com+41 44 5678901200
6f@example.com16+49 89 6789012110
7211

Das Quality Gate fragt nur eine der drei Spalten ab:

  1: SELECT
  2:     customer_id
  3:    ,country_code
  4:    ,email
  5:    ,age
  6:    ,phone
  7: FROM
  8:    staging.customer
  9: WHERE
 10:    sys_error = 0;

Drei der sieben Zeilen passieren das Gate. Die Warnungs-Zeile und die Informations-Zeile fließen mit, die vier Zeilen mit mindestens einem Error bleiben zurück — protokolliert, mit Klartext-Meldung, für die Nacharbeit auffindbar. Nebenbei zeigen die Kombinations-Zeilen, dass sys_error ein Zähler ist und kein Flag, und dass die drei Zähler unabhängig voneinander laufen: Zeile 5 trägt zwei Fehler, Zeile 6 einen Fehler und eine Warnung, Zeile 7 füllt alle drei Zähler gleichzeitig. Das Gate fragt trotzdem nur auf sys_error = 0. Kein Bereitschaftsdienst muss nachts entscheiden, ob eine fehlende Telefonnummer wichtiger ist als der Tagesabschluss. Die Entscheidung ist längst gefallen, im Regel-Review, als jemand W in die Zeile schrieb.

Der Schweregrad ist ein Routing-Entscheid

Bis hierher wirkt der Schweregrad wie ein Etikett am Befund. Er kann mehr: Er beantwortet die Frage, wo eine Regel durchgesetzt werden darf.

Die Überlegung dahinter ist einfach. Ein Constraint im Ziel-Schema ist die härteste Form einer Regel: Aus eigener Kraft kennt er keine Ausnahme, keine Protokoll-Zeile und kein „fließt trotzdem weiter“. Abfedern kann das nur die aufrufende Anwendung, der Constraint selbst kennt keine Abstufung. Diese Härte darf nur eine Regel bekommen, deren Verletzung tatsächlich niemals ins Ziel darf. Das ist exakt die Definition der Error-Stufe. Eine Warnung dagegen soll passieren dürfen, sonst wäre sie ein Error. Ein Constraint, der Warnungs-Verstöße abweist, widerspräche der eigenen Regel-Definition. Daraus folgt das Routing:

  • E ist Kandidat für ein Constraint: NOT NULLCHECKUNIQUE, Fremdschlüssel. Die Regel wird doppelt durchgesetzt: vorab als Prüfung in der Quelle, hart als Garantie am Ziel.
  • W und I bleiben in der Pipeline: Sie existieren nur als Prüfregeln, erzeugen Befunde und blockieren nie. Ein Ziel-Constraint für sie ist nicht etwa unnötig, sondern falsch.

Der Schweregrad ist damit kein Report-Feld, sondern der Schalter, der über die Constraint-Ableitung entscheidet. Wie sich aus dem Ziel-Schema umgekehrt die Error-Regeln der Konfiguration ableiten lassen, zeigt der Schwester-Artikel dieser Serie. Die Kopplung funktioniert in beide Richtungen, und abgeleitete Regeln tragen dort immer E, weil ihre Quelle ein harter Constraint ist.

Konkret sieht das Routing so aus. Die Zielschicht materialisiert die beiden E-Regeln als NOT NULL auf country_code und age, die W-Regeln und die I-Regel haben dort bewusst kein Gegenstück:

  1: CREATE TABLE core.customer
  2: (
  3:     customer_id   int   NOT NULL
  4:    ,country_code  text  NOT NULL
  5:    ,email         text
  6:    ,age           int   NOT NULL
  7:    ,phone         text
  8:    ,CONSTRAINT pk_customer  PRIMARY KEY (customer_id)
  9: );
 10: 
 11: INSERT INTO core.customer (customer_id, country_code, email, age, phone)
 12: SELECT
 13:     customer_id
 14:    ,country_code
 15:    ,email
 16:    ,age
 17:    ,phone
 18: FROM
 19:    staging.customer
 20: WHERE
 21:    sys_error = 0;
 22: -- INSERT 0 3
 

Auch die 17-Jahre-Zeile und die Zeile ohne Telefonnummer kommen an. Genau das ist gewollt: Die Warnung hat ihren Zweck erfüllt (der Befund steht im Protokoll), und das Ziel nimmt den Satz an. Was passieren würde, wenn jemand die Warnungs-Regel doch als Constraint materialisiert, zeigt der direkte Versuch:

  1: ALTER TABLE core.customer
  2:    ADD CONSTRAINT ck_customer_adult CHECK (age >= 18);
  3: -- FEHLER: Check-Constraint ck_customer_adult wird von
  4: --         mindestens einer bestehenden Zeile verletzt

Der ALTER TABLE scheitert am eigenen Bestand, an genau der Zeile, die die Warnung ausdrücklich passieren lassen sollte. Der Fehler ist kein Unfall, sondern die Datenbank, die auf den Widerspruch hinweist: Diese Regel war als W klassifiziert, und ein CHECK ist die Durchsetzungs-Form von E. Für die I-Regel gilt dasselbe mit anderem Vorzeichen: Ihr hartes Gegenstück wäre ein NOT NULL auf phone — technisch problemlos ausdrückbar, aber falsch, weil eine Information nie blockieren soll. Das Ziel erlaubt die fehlende Telefonnummer bewusst, die Pipeline zählt nur mit.

Vier Orte, eine Entscheidung

Zoomt man aus dem Framework heraus, gibt es vier Orte, an denen eine Datenqualitäts-Regel leben kann. Jeder kann etwas, das die anderen nicht können, und jeder hat einen Preis:

OrtWas er kannWas er kostet
Quelle (Vorsieb vor dem Load)findet alle schlechten Sätze vorab, klassifiziert nach Schweregrad, der Load läuft mit den guten weitereigene Infrastruktur: Fehlertabelle, Regel-Config, Runner
Pipeline (Prüf-Schritte im Prozess)flexibelste Ausdruckskraft — jede Regel-Art, jeder Schweregrad, Trends und Reportsdie Regel lebt neben den Daten, jeder Weg an der Pipeline vorbei umgeht sie
Ziel-Schema (Constraints)die einzige Garantie, die jeder Schreibweg respektiert, auch der Hotfix von Handkennt nur zwei Ausgänge, kann nur Error-Semantik, blockiert beim mengen-basierten Load im Zweifel den ganzen Lauf
Reporting (Dashboards, Auswertungen)macht Warnungen und Informationen über die Zeit sichtbar, zeigt Trendserzwingt nichts, ist auf die Befunde der anderen Orte angewiesen

Der Industrie-Default liegt heute klar auf dem zweiten Ort: Werkzeuge wie Great Expectations, Soda oder dbt-Tests formulieren Prüfungen in der Pipeline, und die meisten kennen dafür auch Schweregrad-Abstufungen. dbt etwa unterscheidet error und warn pro Test. Das ist ein tragfähiges Modell, kein Fehler. Was man dabei einkauft, sollte man nur benennen können: Eine Regel, die ausschließlich in der Pipeline lebt, gilt auch nur dort. Der Kollege mit dem direkten INSERT, das zweite Ladeskript, die Migration am Wochenende — keiner davon läuft durch die Pipeline, und keiner trifft ihre Prüfungen. Ein Constraint im Ziel-Schema kennt diese Lücke nicht, denn er sitzt in den Daten selbst.

Die vier Orte sind deshalb keine Konkurrenten, aus denen man einen wählt. Die Entscheidung fällt pro Regel, und der Schweregrad ist ihr erstes Kriterium: Error-Regeln verdienen den doppelten Boden aus Vorsieb und Constraint, Warnungen und Informationen gehören in Pipeline und Reporting.

Warum die Quelle trotzdem zuerst prüft

Wenn nur Error-Regeln Constraints werden, warum prüft man dann überhaupt noch in der Quelle? Man könnte den CHECK doch einfach machen lassen, wofür er da ist.

Die Antwort steht schon im Framework-Artikel, und sie bleibt unter dem Routing-Blick vollständig gültig: Ein Constraint kennt nur zwei Ausgänge. Der Satz passt, oder der ganze Load bricht ab. Beim Laden tausender Zeilen ist „bricht ab“ die schlechteste aller Optionen, denn eine einzige schlechte Zeile stoppt den kompletten Prozess, und die Fehlermeldung nennt bestenfalls diese eine Zeile, nicht die anderen neun, die dahinter noch gewartet hätten.

Eine Präzisierung gehört dazu: Dieses Alles-oder-nichts gilt für den mengen-basierten Load, also für ein einzelnes INSERT … SELECT, das alle Sätze in einer Transaktion bewegt. Genau so arbeitet ein in SQL entwickelter ETL-Prozess, und auch dbt gehört auf diese Seite, weil seine Modelle zu mengen-basierten SQL-Statements kompilieren. Zeilen-basierte ETL-Tools wie SSIS oder Talend verarbeiten die Sätze dagegen einzeln: Ein fehlerhafter Satz lässt sich dort über einen Fehler-Output ausleiten, die übrigen laufen weiter, und der Constraint stoppt nur diese eine Zeile statt des ganzen Loads. Diesen Komfort bezahlt man mit dem zeilen-weisen Verarbeiten selbst, das gegen einen mengen-basierten Load deutlich langsamer ist, und mit einer Fehler-Behandlung, die im Tool lebt statt in einer abfragbaren Tabelle. Das Vorsieb ist die mengen-basierte Antwort auf denselben Bedarf: Es leistet, was der Fehler-Output eines zeilen-basierten Tools leistet — nur vorab, in Mengen-Logik und mit Schweregraden.

Der Fehler-Output hat zudem eine zweite, weniger offensichtliche Schwäche: Die Prüfungen im Datenfluss laufen sequentiell. Der erste Treffer leitet den Satz aus, und die restlichen Prüfungen sehen ihn nie — es sei denn, man verdrahtet den Fehler-Pfad ausdrücklich durch alle weiteren Prüfschritte, was den Datenfluss schnell unübersichtlich macht. In der Praxis heißt das: Man behebt den ersten gefundenen Fehler, lässt den Load erneut laufen, findet den zweiten, behebt ihn, findet den dritten. Das Vorsieb kennt dieses Iterieren nicht, weil jede Regel mengen-basiert über alle Sätze läuft. Die Zeile 7 der Demo steht deshalb nach einem einzigen Lauf vollständig im Protokoll, mit zwei Fehlern, einer Warnung und einer Information.

Das Vorsieb in der Quelle und der Constraint am Ziel sind deshalb keine Alternativen, sondern zwei Hälften derselben Error-Regel. Die Prüfung in der Quelle findet alle Sätze, die am Ziel scheitern würden, klassifiziert sie und lässt den Load mit den sauberen weiterlaufen. Der Constraint am Ziel garantiert die Regel auch für alles, was am Vorsieb vorbeigeht: den manuellen Hotfix, das vergessene Zweitskript. Fällt das Vorsieb aus, bricht der Load laut ab, statt still schlechte Daten anzunehmen. Fällt der Constraint weg, prüft immer noch das Vorsieb. Erst zusammen ergeben beide die Eigenschaft, die keine Hälfte allein hat: vollständige Befunde und eine harte Garantie.

Die Frage ist also nicht, ob Schema oder Pipeline. Die Frage ist, welche Regel beides verdient — und das beantwortet der Schweregrad.

Was beim Hochstufen passiert

Regeln sind nicht statisch. Die Fachseite beschließt, dass Kunden unter 18 ab jetzt nicht mehr angelegt werden dürfen: Aus der Warnung von oben soll ein Error werden. In der Konfiguration ist das eine Zeile:

  1: UPDATE dq.check_rule
  2: SET
  3:    severity = 'E'
  4: WHERE
  5:        schema_name  = 'staging'
  6:    AND table_name   = 'customer'
  7:    AND where_clause = 'age < 18';
  8: 
  9: SELECT dq.fn_run_checks('staging', 'customer') AS total_findings;  -- 11

Ab dem nächsten Lauf blockiert die 17-Jahre-Zeile am Gate (sys_error = 1). Das ist der einfache Teil. Der anspruchsvolle Teil folgt aus dem Routing: Eine Error-Regel ist Constraint-Kandidat, also sollte das Ziel-Schema die neue Härte auch garantieren. Und genau hier wartet der Preis des Hochstufens — der Bestand muss plötzlich sauber sein. Die 17-Jahre-Zeile ist längst im Ziel angekommen, ganz legitim, unter der alten Regel. Der direkte ADD CONSTRAINT scheitert an ihr, wie oben gesehen.

Postgres bietet für diesen Übergang einen kontrollierten Weg. NOT VALID nimmt den Constraint sofort an, ohne den Bestand zu prüfen. Für neue Zeilen gilt er trotzdem ab der ersten Sekunde:

  1: ALTER TABLE core.customer
  2:    ADD CONSTRAINT ck_customer_adult CHECK (age >= 18) NOT VALID;
  3: 
  4: INSERT INTO core.customer (customer_id, country_code, email, age)
  5: VALUES (9, 'DE', 'x@example.com', 16);
  6: -- FEHLER: neue Zeile verletzt Check-Constraint ck_customer_adult
  7: 
  8: DELETE FROM core.customer WHERE age < 18;
  9: 
 10: ALTER TABLE core.customer VALIDATE CONSTRAINT ck_customer_adult;

Die Reihenfolge ist der Punkt. Erst dichtet NOT VALID die Zukunft ab, dann wird der Bestand in Ruhe bereinigt (im Demo per DELETE, in der Praxis eher: zur fachlichen Klärung aussteuern), und erst das abschließende VALIDATE CONSTRAINT prüft den Bestand nach und macht die Garantie vollständig. Zwischen beiden Schritten hat der Constraint einen dokumentierten Zwischenzustand: Er gilt für neue Zeilen, der Bestand ist noch ungeprüft. Postgres zeigt diesen Zustand im Katalog als convalidated = false. Das VALIDATE sperrt die Tabelle dabei nicht für Schreibzugriffe, es läuft mit einer schwachen Sperre neben dem laufenden Betrieb. Eine Feinheit am Rande: Bei NULL schlägt ein CHECK nicht an, ein fehlendes Alter würde ck_customer_adult also anstandslos passieren. Hier fängt das NOT NULL diesen Fall ab, das die E-Regel age IS NULL am Ziel ohnehin materialisiert. Dasselbe Muster trägt auch das nachträgliche NOT NULL auf einer bestehenden Tabelle. Wie sich das ohne Downtime ausrollen lässt, zeigt ein eigener Artikel.

Für SQL Server gilt das Prinzip mit anderer Mechanik: WITH NOCHECK legt einen Constraint ebenfalls an, ohne den Bestand zu prüfen. Der Unterschied steckt im Nachgang. Der Constraint bleibt dauerhaft als „not trusted“ markiert, bis ein WITH CHECK CHECK CONSTRAINT den Bestand nachprüft, und ein nicht vertrauenswürdiger Constraint wird vom Optimizer bei Plan-Entscheidungen ignoriert. Wer nur den ersten Schritt macht, hat die Garantie, aber verschenkt Performance.

Das Herabstufen, der umgekehrte Weg, ist übrigens genauso ein Routing-Entscheid: Aus E wird W, also muss der zugehörige Constraint am Ziel fallen, sonst blockiert er weiter eine Regel, die nur noch beobachten soll. Hochstufen zieht Constraints nach, Herabstufen räumt sie ab — die Konfiguration und das Schema wandern gemeinsam.

Zusammen betrachtet ist das der eigentliche Gewinn der drei Stufen: Eine Regel hat einen kontrollierten Lebenszyklus. Sie beginnt als Beobachtung, bewährt sich, wird hochgestuft und wandert mit NOT VALID und VALIDATE CONSTRAINT als Garantie ins Schema — und bei Bedarf denselben Weg zurück. Der Schweregrad ist die Stellgröße dieses Lebenszyklus, nicht nur ein Etikett am Befund.

Die Grenzen, ehrlich

Das Routing „E wird Constraint“ hat eine Einschränkung, die im Wort Kandidat steckt: Nicht jede Error-Regel kann ein Constraint werden. Ein CHECK sieht genau eine Zeile. Innerhalb dieser Zeile darf er mehrere Spalten vergleichen, ein CHECK (end_date > start_date) ist völlig legitim. Aber drei Regel-Familien liegen außerhalb seiner Reichweite:

  • Zeilenübergreifende Regeln. „Ein Schlüssel darf höchstens dreimal vorkommen“ ist als Prüfregel eine Zeile in der Konfiguration (max_occurrence = 3), mit den deklarativen Constraints (UNIQUECHECK, Fremdschlüssel) aber nicht ausdrückbar, denn UNIQUE kennt nur die Kardinalität 1. Auch Summen-, Anteil- und Verteilungs-Regeln gehören hierher.
  • Tabellenübergreifende Regeln. Ein CHECK darf keine Subquery enthalten, Postgres lehnt das mit einer klaren Fehlermeldung ab. Die einzige tabellenübergreifende Garantie, die das Schema kennt, ist der Fremdschlüssel. Alles jenseits davon bleibt Pipeline-Arbeit, etwa „der Rabatt-Code muss zur Kundengruppe passen“.
  • Zeitliche Regeln. Postgres setzt bei CHECK-Ausdrücken stillschweigend voraus, dass sie immer dasselbe Ergebnis liefern, erzwingt diese Immutabilität aber nicht: Ein CHECK (order_date <= current_date) wird angenommen und ist genau deshalb eine Falle. Was heute gültig ist, ist es morgen nicht mehr, und spätestens beim Restore eines Dumps oder beim VALIDATE schlagen Bestands-Zeilen fehl, die bei ihrer Entstehung korrekt waren. Regeln mit Zeitbezug gehören in die Pipeline, wo jeder Lauf gegen den aktuellen Stichtag prüft.

Wichtig ist die Blickrichtung: Diese Regeln landen nicht in der Pipeline, weil es leider nicht anders geht. Die Pipeline ist für sie der richtige Ort: Sie prüft mengen-basiert, kennt den Lauf-Kontext und kann einen Verstoß trotzdem als Error werten und am Gate blockieren. Eine zeilenübergreifende Error-Regel ist ein Error ohne Constraint-Gegenstück, und das ist kein Widerspruch zum Routing, sondern seine ehrliche Grenze.

Entscheidungsmatrix

Die Entscheidung pro Regel, kompakt:

Regel-ArtTypischer SchweregradOrt der Durchsetzung
Pflichtfeld, das das Ziel erzwingtEVorsieb in der Quelle + NOT NULL am Ziel
Wertebereich, hart (Typ-Grenze, Ländercode-Format)EVorsieb + CHECK am Ziel
Schlüssel-EindeutigkeitEVorsieb + PRIMARY KEY/UNIQUE am Ziel
Referenz auf eine Master-TabelleEVorsieb + FOREIGN KEY am Ziel
Fachliche Auffälligkeit (prüfwürdig, nicht blockierend)WPipeline, Befund im Protokoll
Reine Beobachtung (Häufigkeit, fehlende Optionalwerte)IPipeline + Reporting
Zeilenübergreifend (Kardinalität > 1, Summen, Anteile)E oder Wnur Pipeline — UNIQUE kann keine Kardinalität > 1
Tabellenübergreifend jenseits des FremdschlüsselsE oder Wnur Pipeline — CHECK kann keine Subquery
Zeitbezogen (Datum gegen Stichtag)meist Wnur Pipeline — ein Constraint altert falsch

Zwei Lesarten stecken in der Tabelle. Von oben nach unten: Je härter die Garantie, desto weiter oben steht die Regel, und nur die E-Zeilen erreichen das Schema. Und quer dazu: Der Ort ist nie „entweder Quelle oder Ziel“. Jede Constraint-Zeile trägt beides, weil das Vorsieb die Befunde liefert und der Constraint die Garantie.

FAQ

Warum reichen pass/fail-Prüfungen nicht aus?

Weil sie die Dringlichkeit eines Befunds unterschlagen. Entweder blockiert dann jede Kleinigkeit den Load, oder nichts blockiert und die Befund-Liste verwaist. Die drei Schweregrade Error, Warnung und Information trennen „darf nicht weiter“ von „sollte man sich ansehen“ und „nur zur Kenntnis“ und machen die Reaktion pro Regel konfigurierbar statt pro Nachtschicht verhandelbar.

Welche Prüfregeln gehören in ein CHECK-Constraint?

Nur Error-Regeln, deren Verletzung niemals ins Ziel darf, und auch von denen nur die, die ein Constraint ausdrücken kann: Prüfungen auf einer einzelnen Zeile ohne Zeitbezug und ohne Blick in andere Tabellen. Warnungen gehören nie in ein Constraint, denn ein Constraint, der nicht blockierende Befunde abweist, widerspricht der eigenen Regel-Definition.

Was passiert, wenn ich eine Warnung zu einem Error hochstufe?

Zwei Dinge. Ab dem nächsten Lauf blockieren betroffene Sätze am Quality Gate. Und das Ziel-Schema sollte nachziehen, denn eine Error-Regel verdient ein Constraint-Gegenstück. Dabei muss der Bestand sauber sein: In Postgres nimmt ADD CONSTRAINT … NOT VALID die Garantie sofort für neue Zeilen an, danach wird der Bestand bereinigt und per VALIDATE CONSTRAINT nachgeprüft.

Blockiert eine Warnung den Load?

Nein, und das ist ihr Zweck. Ein Satz mit Warnungs-Befunden passiert das Quality Gate, der Befund bleibt mit Klartext-Meldung im Protokoll stehen. Blockieren soll ausschließlich der Schweregrad Error. Eine Warnung, die den Prozess aufhalten müsste, ist falsch eingestuft und gehört hochgestuft.

Gehört Datenqualität in dbt und Great Expectations oder in die Datenbank?

Beides hat seinen Platz. Pipeline-Werkzeuge wie dbt-Tests, Great Expectations oder Soda sind ausdrucksstark, gut gepflegt und kennen selbst Schweregrad-Abstufungen. Ihre Prüfungen gelten aber nur für Wege durch die Pipeline. Ein direktes INSERT daran vorbei trifft keine davon. Harte Error-Regeln verdienen deshalb zusätzlich ein Constraint im Ziel-Schema, das jeder Schreibweg respektiert.

Verwandte Artikel

Framework und Routinen:

Theorie:

Ziel-Schema und Deployment: