Schema-Änderungen tracken ohne Framework — Run-once-Skripte, Checksummen und Immutabilität

Der Backfill lief beim zweiten Deploy noch einmal — und hat Werte überschrieben, die in der Zwischenzeit fachlich korrigiert worden waren. Solche Unfälle verhindert keine Disziplin, sondern nur ein Gedächtnis: Wer Schema-Änderungen tracken will, ohne gleich Flyway oder Liquibase einzuführen, braucht dafür genau eine Tabelle und rund 40 Zeilen Shell — und trifft dabei dieselben vier Design-Entscheidungen, die auch in jedem Migrations-Tool stecken.

Das Wichtigste vorab:

  • Warum idempotente Objekt-Dateien nicht reichen: daten-abhängige Änderungs-Skripte — Backfill, Umrechnung, Daten-Rettung — dürfen genau einmal pro Datenbank laufen.
  • Design-Entscheidung 1: der Dateiname als Run-once-Schlüssel — eine Tracking-Tabelle mit UNIQUE-Constraint merkt sich, welches Skript schon gelaufen ist.
  • Design-Entscheidung 2: die Checksumme — unveränderte Datei heißt überspringen, veränderte Datei heißt Deploy-Abbruch.
  • Design-Entscheidung 3: Immutabilität — angewandte Änderungs-Skripte werden nie editiert; jede Korrektur ist ein neues Skript.
  • Design-Entscheidung 4: das Henne-Ei-Problem — der Tracker selbst muss existieren, bevor das erste Änderungs-Skript läuft.
  • Warum Flyway und Liquibase im Kern genau dasselbe tun — und wo die Grenzen des Selbstbaus liegen: parallele Deploys, Transaktionslücke, fehlendes Tooling.

Voraussetzung: Postgres und eine Bash mit sha256sum — mehr braucht der Selbstbau nicht. Alle Abläufe sind gegen Postgres 16 in einem Wegwerf-Docker-Container verifiziert, einschließlich des Checksummen-Abbruchs. Die Bausteine stammen aus dem offenen DI²-Starter-Kit auf GitHub, wo sie produktiv und CI-getestet laufen.

Inhalt

Die Lücke im idempotenten Deploy

Das konventionsbasierte Deploy-Modell aus SQL-Schema deployen ohne Migrations-Tool kommt ohne Migrations-Kette aus: Objekt-Dateien beschreiben den Soll-Zustand jeder Tabelle, sind idempotent formuliert (CREATE TABLE IF NOT EXISTSCREATE OR REPLACE) und laufen bei jedem Deploy komplett durch. Für die Struktur funktioniert das — eine Objekt-Datei kann beliebig oft laufen, das Ergebnis ist immer dasselbe.

Sobald Daten im Spiel sind, reißt eine Lücke auf. Nicht jede Datenmigration ist betroffen — manche lässt sich mit Sorgfalt idempotent formulieren. Drei Arbeits-Klassen sperren sich aber ganz oder teilweise gegen die wiederholbare Form:

  • Der Backfill: Bestandszeilen bekommen einen Wert für eine neue Spalte — der klassische Mittelschritt beim nachträglichen NOT NULL. Ein WHERE-Guard (WHERE country_code IS NULL) macht das UPDATE zwar wiederholbar, aber der Guard ist handgemachte Sicherheit: Fehlt er, überschreibt der zweite Lauf, was Nutzer inzwischen korrigiert haben — der Unfall aus dem ersten Absatz.
  • Die Umrechnung: Ein Skript wandelt Bestandswerte um, etwa Preise von Euro in Cent (SET price = price * 100). Hier hilft kein Guard mehr — die Zeile sieht dem Skript nach dem ersten Lauf genauso „unfertig“ aus wie davor. Ein zweiter Lauf verhundertfacht jeden Preis noch einmal.
  • Die Daten-Rettung: Vor einer destruktiven Struktur-Änderung werden Werte beiseite gelegt — etwa der Inhalt einer Spalte, die im selben Deploy fällt. Läuft die Rettung ein zweites Mal, ist die Quelle schon weg oder leer.

Alle drei brauchen dieselbe Garantie: genau einmal pro Datenbank — Run-once. Und diese Garantie kann nicht in der Datei selbst stecken, sie braucht ein Gedächtnis in der Datenbank: eine Tabelle, die festhält, welches Änderungs-Skript hier schon gelaufen ist. Genau so eine Tabelle führt jedes Migrations-Tool — Flyway nennt sie flyway_schema_history, Liquibase DATABASECHANGELOG. Wer sie selbst baut, trifft nacheinander vier Design-Entscheidungen — und versteht danach nebenbei, warum sich die Frameworks so verhalten, wie sie sich verhalten.

Design-Entscheidung 1: der Dateiname als Run-once-Schlüssel

Die Tracking-Tabelle ist bewusst klein — eine Zeile pro angewandtem Änderungs-Skript:

  1: CREATE TABLE IF NOT EXISTS app.schema_change_log
  2: (
  3:     id           bigint         NOT NULL GENERATED ALWAYS AS IDENTITY
  4:    ,filename     varchar(200)   NOT NULL
  5:    ,checksum     varchar(64)    NOT NULL
  6:    ,git_sha      varchar(64)    NOT NULL
  7:    ,applied_on   timestamptz    NOT NULL DEFAULT now()
  8:    ,applied_by   varchar(100)   NOT NULL DEFAULT current_user
  9:
 10:    ,CONSTRAINT pk_schema_change_log           PRIMARY KEY (id)
 11:    ,CONSTRAINT uq_schema_change_log_filename  UNIQUE (filename)
 12: );

Die zentrale Entscheidung steckt in Zeile 11: Der Dateiname ist der Run-once-Schlüssel. Gespeichert wird er samt Pfad (app/postdeploy/202607061000.backfill-customer-country-code.sql), und das UNIQUE-Constraint macht die Identität zur Datenbank-Garantie — dieselbe Datei kann gar nicht zweimal als angewandt registriert werden. Der Timestamp-Präfix YYYYMMDDHHMM. im Namen leistet den zweiten Dienst: Die alphabetische Sortierung der Dateien ist zugleich ihre chronologische Ausführungs-Reihenfolge, ohne dass irgendwo eine Versionsnummer gepflegt werden muss. Eine Konsequenz gehört dazugesagt: Weil der Name die Identität ist, macht ein Umbenennen aus einer angewandten Datei ein scheinbar neues, nie gelaufenes Skript — die Unveränderlichkeit, um die es gleich geht, gilt deshalb auch für den Dateinamen.

Die übrigen Spalten sind Deploy-Metadaten, die sich beim ersten Vorfall bezahlt machen: applied_on und applied_by beantworten „wann und von wem“, git_sha hält fest, von welchem Repository-Stand aus der Deploy lief — ein optionales Extra, das die Frameworks so nicht führen, im Fehlerfall aber den direkten Sprung zum passenden Code-Stand erlaubt. Die checksum-Spalte bekommt gleich ihren eigenen Abschnitt.

Registriert wird über eine kleine Prozedur statt über ein zusammengebautes INSERT — die Werte laufen als Parameter hinein, nicht als String-Konkatenation:

  1: CREATE OR REPLACE PROCEDURE app.sp_ins_schema_change
  2: (
  3:     INOUT p_id            bigint
  4:    ,IN    p_filename      varchar
  5:    ,IN    p_checksum      varchar
  6:    ,IN    p_git_sha       varchar
  7: )
  8: LANGUAGE plpgsql
  9: AS $procedure$
 10: BEGIN
 11:
 12:    INSERT INTO app.schema_change_log
 13:        (
 14:            filename
 15:           ,checksum
 16:           ,git_sha
 17:        )
 18:    VALUES
 19:        (
 20:            trim(p_filename)
 21:           ,trim(p_checksum)
 22:           ,trim(p_git_sha)
 23:        )
 24:    RETURNING id INTO p_id;
 25:
 26: END;
 27: $procedure$;

Nach dem ersten Deploy steht das Backfill-Skript aus dem Eingangs-Beispiel genau einmal im Tracker — der git_sha zeigt hier den Fallback unknown, weil der Test-Lauf außerhalb eines Git-Checkouts stattfand:

                            filename                            | checksum   | git_sha | applied_by
----------------------------------------------------------------+------------+---------+------------
 app/postdeploy/202607061000.backfill-customer-country-code.sql | 589a8c00…  | unknown | postgres

Design-Entscheidung 2: die Checksumme

Der Dateiname allein hätte eine Schwachstelle: Er sagt nur, dass eine Datei dieses Namens gelaufen ist — nicht, welchen Inhalt sie damals hatte. Deshalb berechnet der Deploy-Runner beim Anwenden eine sha256-Checksumme der Datei und legt sie mit ab. Bei jedem weiteren Deploy vergleicht er neu — und daraus ergeben sich exakt drei Fälle:

  1: PSQL=(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1)
  2:
  3: name="app/postdeploy/$(basename "$file")"
  4: checksum="$(sha256sum "$file" | cut -d' ' -f1)"
  5:
  6: <em># Was weiß der Tracker über diese Datei?</em>
  7: <em># (psql-Variable :'fname' quotet injektionssicher  keine String-Konkatenation)</em>
  8: applied="$("${PSQL[@]}" -tA -v "fname=$name" -f - <<'SQL'
  9: SELECT checksum FROM app.schema_change_log WHERE filename = :'fname';
 10: SQL
 11: )"
 12:
 13: if [ -z "$applied" ]; then
 14:   <em># Fall 1: noch nie gelaufen — ausführen, dann als angewandt registrieren</em>
 15:   echo "applying $name"
 16:   "${PSQL[@]}" -f "$file"
 17:   "${PSQL[@]}" -v "fname=$name" -v "sum=$checksum" -v "sha=$GIT_SHA" -f - <<'SQL'
 18: CALL app.sp_ins_schema_change(NULL, :'fname', :'sum', :'sha');
 19: SQL
 20: elif [ "$applied" = "$checksum" ]; then
 21:   <em># Fall 2: schon gelaufen, Datei unverändert — überspringen</em>
 22:   echo "$name skipped (already applied)"
 23: else
 24:   <em># Fall 3: schon gelaufen, Datei nachträglich editiert — Deploy-Abbruch</em>
 25:   echo "Error: $name was applied with checksum $applied" >&2
 26:   echo "       but now hashes to $checksum." >&2
 27:   echo "       Applied change files are immutable — create a new file instead." >&2
 28:   exit 1
 29: fi

Fall 1 und Fall 2 sind der Alltag. Der zweite Deploy quittiert das Backfill-Skript mit einer Zeile und führt nichts erneut aus:

app/postdeploy/202607061000.backfill-customer-country-code.sql skipped (already applied)

Fall 3 ist die eigentliche Design-Entscheidung. Ändert sich die Checksumme einer bereits angewandten Datei, wäre auch ein stilles Überspringen denkbar — oder eine Warnung. Der Runner bricht stattdessen hart ab:

Error: app/postdeploy/202607061000.backfill-customer-country-code.sql was applied with checksum 589a8c00…
       but now hashes to 462a2bdd….
       Applied change files are immutable  create a new file instead.
exit code: 1

Der Abbruch ist die richtige Härte, weil eine editierte angewandte Datei kein Schönheitsfehler ist, sondern ein Widerspruch im System: Die Bestandsumgebung ist mit dem alten Inhalt gebaut, jede neue Umgebung würde mit dem neuen gebaut — zwei Datenbanken, die behaupten, denselben Stand zu haben, und es nicht tun. Eine Warnung würde diesen Widerspruch protokollieren und weiterlaufen lassen; der Abbruch zwingt dazu, ihn aufzulösen, bevor irgendetwas deployt wird. Im verifizierten Negativ-Test blieb der Tracker dabei unverändert und das editierte Skript wurde nicht ausgeführt — der Deploy stoppt, bevor Schaden entsteht.

Design-Entscheidung 3: Immutabilität

Der Checksummen-Abbruch erzwingt eine Regel, die man auch ohne ihn befolgen sollte: Angewandte Änderungs-Skripte sind unveränderlich. Wer im Backfill einen falschen Wert erwischt hat, korrigiert nicht die alte Datei, sondern schreibt ein neues Skript mit neuem Timestamp:

db/schemas/app/postdeploy/
├── 202607061000.backfill-customer-country-code.sql    angewandt, bleibt unverändert
└── 202607071130.fix-customer-country-code-at.sql      die Korrektur ist ein neues Skript

Der Grund liegt in den zwei Wegen, auf denen eine Datenbank zu ihrem Stand kommt. Eine Bestandsumgebung hat das alte Skript bereits ausgeführt — sie sieht nur das Korrektur-Skript. Eine neue Umgebung spielt beim Erst-Deploy beide Skripte chronologisch nach: erst den ursprünglichen Backfill, dann die Korrektur. Beide Wege enden im selben Zustand, und der Tracker dokumentiert lückenlos, was wann passiert ist. Ein nachträglich editiertes Skript hätte diese Konvergenz zerstört — je nachdem, wann eine Umgebung aufgesetzt wurde, trüge sie andere Daten.

„Angewandt“ ist dabei die präzise Grenze der Regel. Solange ein Skript nur auf der eigenen Entwicklungs-Datenbank gelaufen ist, darf es sich noch ändern — die Wegwerf-Umgebung wird ohnehin neu aufgesetzt. Unveränderlich wird es mit der ersten Anwendung auf einer geteilten Umgebung, spätestens mit dem Merge. Flyway kennt für kontrollierte Ausnahmen das repair-Kommando, das die gespeicherten Checksummen neu setzt — der Selbstbau hat bewusst kein Gegenstück, dort heißt die Ausnahme immer: neues Skript.

Aus demselben Grund werden angewandte Skripte auch nicht gelöscht oder „aufgeräumt“: Sie bleiben dauerhaft im Verzeichnis, denn jede künftige Greenfield-Umgebung braucht die vollständige Historie. Auf Bestandsumgebungen kosten sie nichts mehr — der Tracker überspringt sie bei jedem Deploy mit einer einzigen SELECT-Abfrage.

Damit das trägt, muss jedes Änderungs-Skript eine Eigenschaft mitbringen: Es muss auch auf einer leeren, aktuellen Datenbank fehlerfrei durchlaufen. Ein WHERE-Guard macht den Backfill dort zum No-op (UPDATE 0), ein IF EXISTS schützt die Daten-Rettung vor der schon fehlenden Quelle. Im verifizierten Greenfield-Lauf ging genau das durch: null Zeilen angefasst, Constraint gesetzt, Skript als angewandt registriert.

Design-Entscheidung 4: das Henne-Ei-Problem

Die letzte Entscheidung ist unscheinbar, aber ohne sie scheitert der allererste Deploy: Der Runner will vor dem ersten Änderungs-Skript den Tracker befragen — auf einer jungfräulichen Datenbank existiert die Tabelle aber noch gar nicht. Der Test gegen den leeren Container zeigt es unmissverständlich:

ERROR:  relation "app.schema_change_log" does not exist

Verschärft wird das dadurch, dass Änderungs-Skripte nicht nur nach den Objekt-Dateien laufen: Eine Daten-Rettung muss vor der destruktiven Struktur-Änderung greifen, also vor der Tabellen-Sektion des Deploys. Der Tracker kann sich deshalb nicht darauf verlassen, irgendwo „in der Mitte“ mit angelegt zu werden — er muss vor allem anderen existieren.

Die Lösung nutzt die Arbeitsteilung des Deploy-Modells: Tracking-Tabelle und Insert-Prozedur sind selbst gewöhnliche, idempotente Objekt-Dateien — und der Runner wendet genau diese beiden Dateien als allerersten Schritt jedes Deploys an, noch vor allen Änderungs-Skripten. Auf einer Bestandsumgebung ist das ein No-op (IF NOT EXISTS und CREATE OR REPLACE greifen), auf einer neuen Umgebung entsteht der Tracker im selben Moment, in dem er zum ersten Mal gebraucht wird. Dass die beiden Dateien später in der regulären Sektions-Reihenfolge ein zweites Mal laufen, ist aus demselben Grund harmlos — im verifizierten Doppel-Lauf gingen beide Anwendungen fehlerfrei durch.

Flyway löst das identisch, nur unsichtbarer: flyway_schema_history wird automatisch angelegt, bevor die erste Migration läuft.

Dasselbe bei Flyway & Co.

Wer die vier Entscheidungen einmal selbst getroffen hat, liest die Versionstabellen der Frameworks wie einen alten Bekannten:

Design-EntscheidungSelbstbauFlyway (flyway_schema_history)Liquibase (DATABASECHANGELOG)
Run-once-Schlüsselfilename mit UNIQUEVersionsnummer + SkriptnameID + AUTHOR + FILENAME
Checksummesha256CRC32 (checksum-Spalte)MD5SUM-Spalte
ImmutabilitätDeploy-Abbruch bei Mismatch„Migration checksum mismatch“ bei validate/migrateChecksummen-Prüfung bei jedem update
Henne-EiTracker-Objekt-Dateien laufen zuerstTabelle wird automatisch angelegtTabelle wird automatisch angelegt

Der oft gefürchtete Flyway-Fehler „Migration checksum mismatch“ ist also kein Framework-Ärgernis, sondern exakt die Design-Entscheidung 2: Jemand hat eine bereits angewandte Migration editiert, und das Tool weigert sich, den Widerspruch zu ignorieren. Die Doku-Referenzen: Flyway schema history table und Liquibase DATABASECHANGELOG.

Die Grenzen des Selbstbaus

Der Selbstbau liefert den Kern — Run-once, Checksumme, Abbruch — mit einer Tabelle und rund 40 Zeilen Shell, ohne neue Abhängigkeit im Stack. Ehrlich wird das Bild aber erst mit den Grenzen, und zwei davon stecken im gezeigten Kern selbst:

  • Parallele Deploys. Zwischen der Tracker-Abfrage und der Registrierung liegt ein Zeitfenster: Starten zwei Deploys gleichzeitig, sehen beide „noch nicht gelaufen“ und führen dasselbe Skript doppelt aus — das UNIQUE-Constraint fängt erst den zweiten INSERT ab, nicht die zweite Ausführung. Die Frameworks lösen das mit einer Lock-Tabelle (Liquibase: DATABASECHANGELOGLOCK); in Postgres genügt dafür ein pg_advisory_lock am Deploy-Anfang. Der gezeigte Kern verlässt sich stattdessen darauf, dass die Deploy-Pipeline nur einen Lauf zur Zeit fährt — eine Annahme, die man kennen und in der CI absichern sollte.
  • Die Lücke zwischen Ausführung und Registrierung. Änderungs-Skript und CALL laufen als zwei getrennte psql-Aufrufe. Stürzt der Deploy genau dazwischen ab, ist das Skript gelaufen, gilt aber als nicht angewandt — und liefe beim nächsten Deploy erneut. Wer das schließen will, nimmt Skript und Registrierung in eine gemeinsame Transaktion (psql --single-transaction mit beiden Dateien). Die Restgrenze teilt der Selbstbau mit den Frameworks: Statements wie CREATE INDEX CONCURRENTLY vertragen keine umgebende Transaktion.

Dazu kommt das Tooling, das ein Framework rund um seine Versionstabelle mitbringt und der Selbstbau schlicht nicht hat: kein repair-Kommando für kontrollierte Checksummen-Korrekturen, keine Baselines für den Einstieg in Bestands-Datenbanken, keine Out-of-order-Migrationen für parallel arbeitende Teams, keine Repeatable Migrations, keine Rollback-Workflows (Liquibase), kein Multi-Engine-Support, keine programmatischen Migrationen. Wer eine Engine, ein Team und ohnehin einen Shell-basierten, serialisierten Deploy hat, kommt mit dem Selbstbau weit; wer mehrere dieser Punkte braucht, ist mit dem Framework besser bedient — und versteht es nach diesem Artikel von innen.

FAQ

Wie protokolliert man Schema-Änderungen in PostgreSQL?

Für den Deploy-Zustand reicht eine Tracking-Tabelle: eine Zeile pro angewandtem Änderungs-Skript, mit Dateiname als UNIQUE-Schlüssel, Checksumme, Git-SHA und Zeitstempel. Sie beantwortet „welches Skript ist auf dieser Datenbank schon gelaufen“. Ein vollständiges DDL-Audit — wer hat wann manuell ein ALTER TABLE abgesetzt — ist ein anderes Werkzeug: Dafür sind Event-Trigger oder die pgaudit-Extension zuständig, nicht der Deploy-Tracker.

Warum bricht Flyway mit „checksum mismatch“ ab?

Weil eine bereits angewandte Migrations-Datei nachträglich verändert wurde. Flyway speichert beim Anwenden eine Checksumme und vergleicht sie bei jedem Lauf neu — weicht sie ab, existieren zwei Wahrheiten: Bestandsumgebungen liefen mit dem alten Inhalt, neue bekämen den neuen. Der Abbruch erzwingt die Auflösung. Sauberer Weg: die Änderung zurücknehmen und als neue Migration einspielen.

Darf man eine bereits angewandte Migration editieren?

Nein — angewandte Änderungs-Skripte sind unveränderlich. Jede Korrektur wird ein neues Skript mit eigenem Timestamp, das die Änderung nachzieht. Nur so kommen Bestandsumgebungen (die nur die Korrektur nachfahren) und neue Umgebungen (die die komplette Historie chronologisch nachspielen) am selben Zustand an. Das gilt für den Selbstbau genauso wie für Flyway und Liquibase.

Braucht man für Schema-Änderungs-Tracking ein Framework?

Nicht zwingend. Der Kern — Run-once-Schlüssel, Checksumme, Abbruch bei Veränderung, Tracker-vor-allem-anderen — passt in eine Tabelle und rund 40 Zeilen Shell. Ein Framework lohnt sich, wenn seine Zusatzleistungen gebraucht werden: Rollback-Unterstützung, Schutz gegen parallel laufende Deploys, Dry-Run-Tooling oder Unterstützung mehrerer Datenbank-Engines.

Was passiert mit den Änderungs-Skripten auf einer neuen, leeren Datenbank?

Sie laufen beim Erst-Deploy alle genau einmal, in chronologischer Reihenfolge des Timestamp-Präfixes. Deshalb muss jedes Skript auch auf einer leeren, aktuellen Datenbank fehlerfrei durchlaufen — ein WHERE-Guard macht den Backfill dort zum No-op, ein IF EXISTS schützt vor schon fehlenden Quellen. Danach ist der Tracker der neuen Umgebung genauso gefüllt wie der jeder Bestandsumgebung.

Verwandte Artikel