Die Zeilenzahlen stimmen — Tabelle für Tabelle, Quelle gegen Ziel, alles grün. Und trotzdem ist die Migration nicht fertig. In einer Spalte sind aus NULL-Werten leere Strings geworden, in einer anderen hat der Umweg über eine CSV-Datei die letzte Nachkommastelle eines Betrags gerundet, und ein paar Umlaute sind zu Fragezeichen zerfallen. Gleiche Anzahl ist nicht gleiche Daten — und genau das ist die Lücke, die ein reiner count(*)-Vergleich nie schließt.
Dieser Artikel ist die Verifikations-Phase: der „bist du wirklich fertig?“-Schritt nach dem Umzug von SQL Server nach PostgreSQL. Er baut den Abgleich in Stufen auf — von der billigen Zeilenzählung über interpretierbare Spalten-Aggregate bis zum Zeilen-Hash für die kritischen Tabellen — und zeigt, welche stillen Differenzen erst dabei sichtbar werden.
Das Wichtigste vorab:
- Zeilen-Abgleich ist die notwendige, aber nicht hinreichende Bedingung:
count(*)pro Tabelle, als Differenz-Report über alle Tabellen auf einmal. - Spalten-Aggregate (
sum,min/max, Anzahl Nicht-NULL) sind der robuste, engine-neutrale Erst-Check für den Inhalt — sie zeigen wo etwas driftet, nicht nur dass (beweisen aber keine Gleichheit). - Zeilen-Hashes (
HASHBYTES('MD5', ...)↔md5(string_agg(...))) verdichten den ganzen Zeileninhalt — aber nur mit deterministischer Sortierung und byte-identischer Normalisierung. - Die teuren Tabellen verifizierst du per Sampling über Schlüssel-Ranges — niemals über Zufalls-Stichproben, die auf beiden Seiten verschiedene Zeilen ziehen.
Voraussetzung: SQL Server als Quelle, PostgreSQL 14+ als Ziel (die Beispiele laufen gegen PostgreSQL 16). Aggregatfunktionen (count, sum, min/max) werden vorausgesetzt; die Postgres-Spezifika (md5, string_agg, query_to_xml, Katalog-Schätzungen) werden gezeigt. Der eigentliche Datentransfer (bcp, COPY, pgloader) und das Typ-Mapping sind eigene Themen — beide sind unten verlinkt; hier geht es um den Soll/Ist-Vergleich danach.
Inhalt
- Stufe 1: Zeilen-Abgleich
- Stufe 2: Prüfsummen statt nur zählen
- Stufe 3: Was der Hash nicht verrät
- Strukturelle Checks: Fremdschlüssel und Sequenzen
- Das generisch fahren: das Datenqualitäts-Framework
- Sampling bei sehr großen Tabellen
- FAQ
- Verwandte Artikel
Stufe 1: Zeilen-Abgleich
Der erste Check ist der billigste und der wichtigste: Hat jede Tabelle nach dem Transfer genauso viele Zeilen wie vorher? Wenn nicht, ist die Migration nachweisbar unvollständig — kein weiterer Check muss laufen, bevor diese Differenz geklärt ist.
Für eine einzelne Tabelle ist das trivial und auf beiden Engines identisch:
1: -- SQL Server (Quelle)
2: SELECT count(*) FROM dbo.customer;
3:
4: -- PostgreSQL (Ziel)
5: SELECT count(*) FROM customer;
Bei drei Tabellen tippt man das von Hand. Bei dreihundert nicht. Auf der Postgres-Seite lassen sich alle Zeilenzahlen mit einem einzigen Statement ziehen — der Trick ist query_to_xml, das ein dynamisches SELECT count(*) pro Tabelle ausführt und das Ergebnis als XML zurückgibt, aus dem xpath die Zahl wieder herausholt:
1: SELECT
2: table_name
3: ,(xpath(
4: '/row/c/text()'
5: ,query_to_xml(
6: format('SELECT count(*) AS c FROM %I.%I', table_schema, table_name)
7: ,false, true, ''
8: )
9: ))[1]::text::bigint AS row_count
10: FROM
11: information_schema.tables
12: WHERE
13: table_schema = 'public'
14: AND table_type = 'BASE TABLE'
15: ORDER BY
16: table_name;
Das Ergebnis ist eine Liste table_name | row_count über das ganze Schema. Die exakt gleiche Liste zieht man auf der SQL-Server-Seite (dort etwa über sys.tables und sys.partitions), legt beide nebeneinander und sucht die Zeilen, in denen die Zahlen auseinanderlaufen.
Eine Abkürzung lohnt einen Blick — und eine Warnung. PostgreSQL führt im Systemkatalog eine geschätzte Zeilenzahl pro Tabelle:
1: SELECT
2: relname AS table_name
3: ,reltuples::bigint AS estimated_rows
4: FROM
5: pg_class
6: WHERE
7: relkind = 'r'
8: AND relnamespace = 'public'::regnamespace
9: ORDER BY
10: relname;
Das ist in Millisekunden da, auch bei Milliardenzeilen — aber reltuples ist eine Schätzung, die der Planer pflegt, und sie stimmt erst nach einem ANALYZE halbwegs und auch dann nicht auf die Zeile genau. Für einen ersten Smoke-Test („grob in derselben Größenordnung?“) ist sie genau das Richtige; als Beweis, dass kein Datensatz fehlt, taugt sie nicht. Die Verifikation braucht das exakte count(*).
Und selbst das exakte count(*) ist nur die notwendige Bedingung. Es sagt: gleich viele Zeilen. Es sagt nichts darüber, ob in diesen Zeilen dasselbe steht.
Stufe 2: Prüfsummen statt nur zählen
Um den Inhalt zu vergleichen, gibt es zwei Wege — und der naheliegende ist nicht der beste. Fangen wir mit dem robusten an.
Spalten-Aggregate. Statt jede Zeile zu hashen, rechnet man pro Tabelle eine Handvoll Aggregate aus, die jede für sich interpretierbar ist:
1: -- identisch auf beiden Engines (SQL Server: FROM dbo.customer)
2: SELECT
3: count(*) AS row_count
4: ,count(email) AS non_null_emails
5: ,sum(credit_limit) AS sum_credit
6: ,min(created_at) AS min_created
7: ,max(created_at) AS max_created
8: FROM
9: customer;
Dieser Check ist deshalb so wertvoll, weil er engine-neutral ist: sum, min, max und count bedeuten in SQL Server und Postgres dasselbe, und das Ergebnis ist eine kleine Zahlenzeile, die man direkt nebeneinanderlegen kann. Weicht sum_credit um einen Cent ab, weißt du sofort: Es liegt an den Beträgen, nicht an den Namen. Weicht max_created ab, ist es ein Datums-Problem. Der Check zeigt wo, nicht nur dass — und genau das spart bei der Fehlersuche die meiste Zeit.
Eine Einschränkung gehört aber dazu: Übereinstimmende Aggregate beweisen keine Gleichheit. Verschiebt sich ein Wert um +10 und ein anderer in derselben Spalte um −10, bleibt die Summe identisch; dieselben min/max-Grenzen lassen sich mit ganz anderen Werten dazwischen erzeugen. Die Aggregate sind ein sehr robuster erster Inhaltsvergleich — den lückenlosen Abgleich liefert erst der Zeilen-Hash für die kritischen Tabellen.
Zeilen-Hashes. Der gründlichere Weg verdichtet jede Zeile zu einem Hash und aggregiert alle Zeilen-Hashes zu einer Tabellen-Prüfsumme. (MD5 dient hier als schnelle Prüfsumme, nicht aus kryptografischen Gründen — die theoretische Kollisionsgefahr ist für einen Datenabgleich irrelevant; ein gleicher Hash bedeutet also „mit an Sicherheit grenzender Wahrscheinlichkeit gleicher Inhalt“.) In Postgres:
1: SELECT
2: md5(string_agg(row_hash, '' ORDER BY customer_id)) AS table_hash
3: FROM (
4: SELECT
5: customer_id
6: ,md5(concat_ws('|'
7: ,customer_id::text
8: ,coalesce(email, '\N')
9: ,coalesce(credit_limit::text, '\N')
10: )) AS row_hash
11: FROM
12: customer
13: ) T01;
Auf der SQL-Server-Seite baut man dieselbe normalisierte Zeichenkette und hasht sie mit HASHBYTES('MD5', ...). Weil beide Seiten MD5 verwenden, sind die Hashes direkt vergleichbar — aber nur, wenn die Eingabe byte-genau gleich ist. Und da liegen drei Tücken, die jeden Hash-Vergleich falsch-negativ machen können:
- Sortierung.
string_aggohneORDER BYfügt die Zeilen in beliebiger Reihenfolge zusammen — der Hash ist dann bei jedem Lauf anders. Es braucht eine deterministische Sortierung über den Primärschlüssel, auf beiden Seiten dieselbe. - NULL-Darstellung und Trennzeichen.
coalesce(..., '\N')ersetztNULLdurch einen Platzhalter, der in den echten Daten nicht vorkommt. Ohne diesen Schritt sind('a', NULL)und(NULL, 'a')nach der Verkettung ununterscheidbar. Das Trennzeichen (|) muss ebenso ein Zeichen sein, das in keiner Spalte auftaucht. - Encoding und Zahl-/Datumsformat. Die eigentliche Cross-Engine-Falle: Wird
HASHBYTESdirekt aufnvarcharangewendet, hasht SQL Server die UTF-16-Bytefolge, Postgres dagegen UTF-8 — bei Nicht-ASCII-Daten kommen verschiedene MD5-Werte heraus, obwohl der Text identisch ist. Vermeiden lässt sich das, indem man quellseitig vor dem Hashen explizit nach UTF-8 serialisiert. Encoding ist dabei aber nur eine Fehlerquelle: Genauso verschiebencredit_limit::text(Dezimalpunkt!), Datumsformate, nachgestellte Leerzeichen auschar(n)und implizite Casts den Bytewert — dieselben kosmetischen Repräsentations-Unterschiede wie im nächsten Abschnitt. Reines ASCII schützt also nicht automatisch; verlässlich ist der direkte MD5-Vergleich erst mit auf beiden Seiten identisch normalisierter Serialisierung. Wo dieser Aufwand zu hoch ist, sind die werttreuen Spalten-Aggregate der robustere Weg.
Die Reihenfolge in der Praxis: count(*) immer, Spalten-Aggregate für alle Tabellen, Zeilen-Hash nur für die kritischen — Stammdaten, Finanzbewegungen, alles, wo eine einzelne falsche Zeile teuer ist.
Stufe 3: Was der Hash nicht verrät
Ein Hash ist binär: gleich oder ungleich. Schlägt er an, weißt du, dass irgendwo etwas anders ist — aber nicht was, nicht wo und nicht, ob es überhaupt ein Fehler ist. Denn manche Differenzen sind beim Umzug zwischen zwei Engines erwartbar, und ein bit-genauer Hash meldet sie trotzdem als Abweichung.
Das deutlichste Beispiel ist eine bit-Spalte, die in Postgres zu boolean wird. Der Wert ist semantisch identisch — aber serialisiert liefert die Quelle '1'/'0' und das Ziel 'true'/'false'. Ein Zeilen-Hash über die Textdarstellung weicht damit in jeder Zeile ab, obwohl kein einziger Datensatz falsch übertragen wurde. Dieselbe Art rein kosmetischer Differenz entsteht durch nachgestellte Nullen in Dezimalzahlen (numeric(19, 2) serialisiert 1000.00, numeric(19, 4) dagegen 1000.0000), durch das Millisekunden-Suffix bei Zeitstempeln (10:00:00.000 gegen 10:00:00) oder durch aufgefüllte Leerzeichen aus einer char(n)-Spalte:
1: -- Semantisch gleicher Wert, andere Serialisierung -> anderer Hash
2: SELECT
3: md5('1') AS bit_quelle -- SQL Server: bit
4: ,md5('true') AS boolean_ziel -- PostgreSQL: boolean
5: ,md5('1000.00') AS dezimal_quelle -- numeric(19, 2)
6: ,md5('1000.0000') AS dezimal_ziel; -- numeric(19, 4)
Alle vier Hashes sind verschieden, obwohl je zwei denselben Wert meinen. Solche Repräsentations-Unterschiede muss man vor dem Hashen normalisieren (Boolean auf ein einheitliches Format casten, Dezimalzahlen auf dieselbe Skala runden, Zeitstempel identisch formatieren) — sonst ertrinkt das eine echte Problem in hundert kosmetischen Fehlalarmen. Genau deshalb sind die werttreuen Spalten-Aggregate aus Stufe 2 und die gezielten Checks hier oft verlässlicher als ein bit-genauer Hash über die Rohdarstellung.
Die folgenden Checks machen die echten migrations-induzierten Differenzen sichtbar — die, bei denen tatsächlich ein Wert verloren ging.
Leerstring statt NULL. Die klassische Transfer-Falle: Läuft der Umzug über CSV, werden NULL-Werte je nach Export-Einstellung zu leeren Strings — die Zeilenzahl bleibt gleich, der Inhalt nicht.
Der naheliegende Reflex — „zähl im Ziel die Leerstrings“ — greift hier zu kurz. Die Quelle kann selbst schon legitime Leerstrings enthalten haben; eine bloße Leerstring-Zahl auf der Zielseite hat also keinen Bezugswert, gegen den sie etwas beweist. Aussagekräftig ist erst der Vergleich beider Seiten, und zwar getrennt nach NULL und Leerstring:
1: -- NULL und Leerstring getrennt zählen — dieselbe Query auf Quelle und Ziel
2: SELECT
3: count(*) - count(email) AS null_count
4: ,count(CASE WHEN email = '' THEN 1 END) AS empty_count
5: FROM
6: customer;
count(*) - count(email) ist die Anzahl der NULL-Werte (count über eine Spalte ignoriert NULL), empty_count die der leeren Strings. In einer sauberen Migration stimmen beide Zahlen zwischen Quelle und Ziel überein. Kippt der Transfer dagegen NULL in Leerstring, verschiebt sich das Verhältnis: Das Ziel hat weniger NULL-Werte und um genau dieselbe Differenz mehr Leerstrings als die Quelle. Die Summe aus beiden bleibt konstant — deshalb merkt weder die Zeilenzahl noch ein reiner Leerstring-Zähler etwas. Erst das Auseinanderlaufen der beiden Einzelwerte im Seitenvergleich macht den stillen Typwechsel sichtbar.
Abgeschnittene Strings. Wurde eine Spalte zu knapp dimensioniert (oder eine nvarchar(max)-Quelle auf ein Limit gemappt), schneidet der Import lange Werte ab — lautlos. Der saubere Nachweis ist auch hier der Seitenvergleich: die maximale Stringlänge in Quelle und Ziel gegenüberstellen.
1: -- maximale Länge je Spalte — Quelle gegen Ziel stellen
2: SELECT max(length(email)) AS max_email_len
3: FROM customer;
Ist das Ziel-Maximum kürzer als das der Quelle, wurde abgeschnitten. Ist die Quelle nach der Migration nicht mehr greifbar, bleibt nur die schwächere Heuristik: Klebt das Maximum exakt auf der Spaltenbreite (etwa genau 50), kann das ein Abschnitt sein — ein Beweis ist es nicht, denn ein echter Wert von genau 50 Zeichen ist völlig legitim. Dann lohnt eine gezielte Stichprobe der Grenzwerte.
Gerundete Zahlen, verschobene Datumswerte. Ein auf zu wenige Nachkommastellen gemapptes decimal/money oder ein Float, der den Umweg über eine Textdatei genommen hat, kann auf der letzten Stelle kippen. Datumswerte trifft es auf zwei Arten: Ein hochpräziser datetime2(7) verliert beim Export über eine CSV-Datei Sekundenbruchteile, und ein datetimeoffset, das nach timestamptz umzieht, verschiebt sich um Stunden, falls der Import die Ausgangs-Zeitzone falsch annimmt. (Die grobe ~3,3-ms-Granularität des alten datetime-Typs ist dagegen keine Umzugs-Differenz — dieser bereits gerundete Wert wandert unverändert mit.) Solche Abweichungen fängt man nicht über count, sondern über die Summe (bei Beträgen) und die Extremwerte min/max (bei Datums- wie Zahlenspalten) aus Stufe 2 — und für die genaue Lokalisierung über einen direkten Differenz-Join, wenn beide Datenbanken zugleich erreichbar sind (etwa per Foreign Data Wrapper). Welche Typen beim Umzug kippen und welche sauber durchgehen, klärt der Datentyp-Artikel unten; hier zählt nur: Die Summe einer Geldspalte — oder der Maximalwert einer Zeitstempel-Spalte — ist ein schärferer Wächter als ihre Zeilenzahl.
Diese drei Checks sind kein vollständiger Katalog, sondern die Stellen, an denen erfahrungsgemäß zuerst etwas schiefgeht. Sie haben eine Gemeinsamkeit: Anders als der Hash liefern sie eine interpretierbare Zahl, mit der man die Ursache eingrenzen kann.
Strukturelle Checks: Fremdschlüssel und Sequenzen
Über die Inhalte hinaus gibt es zwei strukturelle Prüfungen, die nach jeder Migration dazugehören — weil sie genau die Fehler finden, die erst im laufenden Betrieb auffallen würden.
Verwaiste Fremdschlüssel. Wenn die Constraints während des Transfers deaktiviert waren (ein üblicher Schritt, um die Ladereihenfolge zu entspannen), kann es Kindzeilen ohne passende Elternzeile geben. Ein LEFT JOIN mit IS NULL auf der Elternseite zählt sie:
1: SELECT count(*) AS orphaned_orders
2: FROM
3: orders T01
4: LEFT JOIN customer T02
5: ON
6: T02.customer_id = T01.customer_id
7: WHERE
8: T02.customer_id IS NULL;
Das Ergebnis muss 0 sein. Ist es das, lassen sich die Constraints anschließend sauber wieder VALIDATE-n — der Schema-Artikel unten geht auf die Constraint-Revalidierung ein.
Sequenz-Höchstwert. Der häufigste Post-Load-Bug überhaupt: Die Daten sind drin, aber die Sequenz hinter der IDENTITY-Spalte wurde nicht auf den Höchstwert nachgezogen. Der nächste INSERT vergibt dann eine Schlüssel-ID, die bereits existiert — und kollidiert.
1: SELECT
2: max(customer_id) AS max_id
3: ,(SELECT last_value FROM customer_customer_id_seq) AS seq_value;
seq_value muss >= max_id sein. Ist die Sequenz zu niedrig, setzt man sie mit setval() korrekt — das gehört zum Abschluss jeder Migration mit künstlichen Schlüsseln und ist Teil der Schema-Migration (Verweis unten).
Das generisch fahren: das Datenqualitäts-Framework
Die Checks oben sind handgeschrieben — eine Query pro Tabelle, pro Spalte, pro Regel. Bei einem überschaubaren Schema ist das genau richtig. Bei Hunderten von Tabellen wird daraus Fleißarbeit, die nach Automatisierung ruft.
Genau dafür gibt es das konfigurierbare Datenqualitäts-Framework, das in einem eigenen Artikel ausführlich behandelt ist: Prüfregeln stehen als Zeilen in einer Konfigurationstabelle, eine Routine baut daraus per dynamischem SQL die konkreten Checks und schreibt die Treffer in eine gemeinsame Fehlertabelle. Die NULL-Quoten-, Längen- und Range-Checks aus Stufe 3 sind nichts anderes als generische Prüfregeln — statt sie für jede Spalte abzutippen, hinterlegt man sie einmal als Konfiguration und lässt sie über alle Tabellen laufen.
Die Verifikation einer Migration ist damit ein Spezialfall angewandter Datenqualität: Soll-Zustand ist „identisch zur Quelle“, die Regeln sind dieselben, die man auch im laufenden ETL-Betrieb fährt. Wie dieses Framework aufgebaut ist und wie die dynamische Regel-Auswertung sicher (mit %I/%L statt String-Verkettung) funktioniert, steht im verlinkten Artikel — hier reicht der Hinweis, dass die wiederkehrenden Checks nicht handgeschrieben bleiben müssen.
Sampling bei sehr großen Tabellen
Ein Voll-Hash über eine Milliarde Zeilen liest die ganze Tabelle — auf beiden Seiten. Wenn das zu teuer wird, ist Sampling die pragmatische Alternative. Aber es gibt eine Regel, an der die meisten ersten Versuche scheitern: Das Sampling muss auf beiden Engines exakt dieselben Zeilen ziehen.
Eine zufällige Stichprobe (TABLESAMPLE, ORDER BY random()) tut das nicht — sie zieht in Postgres andere Zeilen als in SQL Server, und der Vergleich zweier unterschiedlicher Stichproben sagt nichts aus. Was funktioniert, ist deterministisches Sampling über den Primärschlüssel: eine reproduzierbare Auswahl, die auf beiden Seiten dieselbe Teilmenge trifft.
1: -- Jede 100. Zeile, deterministisch über den Schlüssel gewählt
2: SELECT
3: md5(string_agg(customer_id::text, '' ORDER BY customer_id)) AS sample_hash
4: FROM
5: customer
6: WHERE
7: customer_id % 100 = 0;
Dieselbe WHERE-Bedingung (customer_id % 100 = 0) trifft auf der SQL-Server-Seite dieselben Schlüssel — die beiden Stichproben sind deckungsgleich und damit vergleichbar. Alternativ teilt man den Schlüsselbereich in Blöcke (BETWEEN) und hasht blockweise; das erlaubt es, eine angeschlagene Region einzugrenzen, ohne die ganze Tabelle erneut zu lesen.
Die ehrliche Einordnung: Sampling reduziert das Risiko, es eliminiert es nicht. Es findet systematische Fehler zuverlässig — ein falsch gemappter Typ oder ein durchgängiger Encoding-Schaden trifft jede Stichprobe —, eine einzelne falsche Zeile zwischen zwei Stichprobenpunkten bleibt dagegen unentdeckt. Für die kritischen Tabellen führt am Voll-Hash deshalb nichts vorbei — Sampling ist das Werkzeug für die große, unkritische Masse, bei der ein Vollabgleich in keinem Verhältnis zum Nutzen steht.
FAQ
Nein. Der count(*)-Vergleich ist die notwendige, aber nicht die hinreichende Bedingung: Er beweist, dass keine Zeile fehlt, aber nicht, dass in den Zeilen dasselbe steht. Gerundete Beträge, zu Leerstrings gekippte NULL-Werte, abgeschnittene Texte und Encoding-Schäden hinterlassen die Zeilenzahl unberührt. Erst Spalten-Aggregate (sum, min/max) und Zeilen-Hashes prüfen den Inhalt.
Über eine normalisierte Repräsentation auf beiden Seiten. Der Weg ist, jede Zeile auf beiden Seiten zu einer identischen Zeichenkette zu serialisieren (gleiches Trennzeichen, gleicher NULL-Platzhalter, gleiches Zahl-/Datumsformat) und diese mit MD5 zu hashen — HASHBYTES('MD5', ...) in SQL Server, md5(...) in Postgres. Wird HASHBYTES direkt auf nvarchar angewendet, muss zusätzlich das Encoding angeglichen werden (SQL Server hasht dann UTF-16, Postgres UTF-8), sonst weichen die Hashes trotz gleichem Text ab — quellseitig hilft eine UTF-8-Serialisierung vor dem Hashen.
Meist liegt es am Typ-Mapping oder am Transferweg, nicht an den Daten selbst. Ein money oder decimal, das auf zu wenige Nachkommastellen gemappt wurde, rundet beim Laden; ein Float, der über eine CSV-Datei lief, kann auf der letzten Stelle kippen. Die Summe der Spalte (Stufe 2) zeigt die Abweichung sofort, die Zeilenzahl nie. Welche Typen sauber konvertieren und welche stille Differenzen erzeugen, behandelt der Datentyp-Artikel.
float/real) zuverlässig? Nicht auf exakte Gleichheit, sondern mit Toleranz. float und real sind auf beiden Engines IEEE 754 — bei binärem Transfer bit-identisch, aber sobald der Weg über eine Textdarstellung (CSV) führt, kann die letzte signifikante Stelle kippen, weil ein double 17 signifikante Stellen für den verlustfreien Roundtrip braucht. Dann vergleicht man den relativen Fehler abs(a - b) / abs(a) gegen eine Schwelle (etwa 1e-12) oder rundet beide Seiten mit round(wert::numeric, 10), bevor man hasht. Wie viele Stellen stimmen müssen, ist eine fachliche Entscheidung, kein technisches Detail. Und Vorsicht: Schon sum() über viele Gleitkommawerte ist reihenfolgenabhängig und damit nicht bit-stabil — auch das Aggregat vergleicht man nur „nah genug“, nie exakt.
Mit deterministischem Sampling über den Primärschlüssel (customer_id % 100 = 0 oder Schlüssel-Ranges per BETWEEN) — niemals mit Zufalls-Stichproben, die auf beiden Engines verschiedene Zeilen ziehen. Für die wirklich kritischen Tabellen bleibt der Voll-Hash trotzdem Pflicht; Sampling deckt die große, unkritische Masse ab und ist ein Risiko-Kompromiss, kein Beweis.
Wenn pro Tabelle die Zeilenzahl exakt stimmt, die Spalten-Aggregate übereinstimmen, die kritischen Tabellen einen identischen Zeilen-Hash liefern, keine verwaisten Fremdschlüssel existieren und die Sequenzen auf dem Höchstwert stehen. Erst dann ist „die Daten sind drüben“ mehr als eine Vermutung.
Verwandte Artikel
Dieser Artikel ist Teil einer Serie zur Migration von SQL Server nach PostgreSQL. Die übrigen Teile:
- Überblick: Datenmigration SQL Server nach PostgreSQL — der vollständige Leitfaden (folgt)
- Datentypen: Datentyp-Mapping SQL Server → PostgreSQL — was sauber konvertiert und was kippt
- Schema: Schema-Migration SQL Server → PostgreSQL — Identity, Constraints, Defaults, Sequenzen
- Datentransfer: Daten transferieren: bcp, COPY, pgloader, ETL — welche Methode wann
- Code-Portierung: T-SQL nach PL/pgSQL portieren — Prozeduren, Funktionen und die letzten 20 %
Weil die Post-Load-Verifikation angewandte Datenqualität ist: