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
- Design-Entscheidung 1: der Dateiname als Run-once-Schlüssel
- Design-Entscheidung 2: die Checksumme
- Design-Entscheidung 3: Immutabilität
- Design-Entscheidung 4: das Henne-Ei-Problem
- Dasselbe bei Flyway & Co.
- Die Grenzen des Selbstbaus
- FAQ
- Verwandte Artikel
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 EXISTS, CREATE 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. EinWHERE-Guard (WHERE country_code IS NULL) macht dasUPDATEzwar 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-Entscheidung | Selbstbau | Flyway (flyway_schema_history) | Liquibase (DATABASECHANGELOG) |
|---|---|---|---|
| Run-once-Schlüssel | filename mit UNIQUE | Versionsnummer + Skriptname | ID + AUTHOR + FILENAME |
| Checksumme | sha256 | CRC32 (checksum-Spalte) | MD5SUM-Spalte |
| Immutabilität | Deploy-Abbruch bei Mismatch | „Migration checksum mismatch“ bei validate/migrate | Checksummen-Prüfung bei jedem update |
| Henne-Ei | Tracker-Objekt-Dateien laufen zuerst | Tabelle wird automatisch angelegt | Tabelle 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 zweitenINSERTab, nicht die zweite Ausführung. Die Frameworks lösen das mit einer Lock-Tabelle (Liquibase:DATABASECHANGELOGLOCK); in Postgres genügt dafür einpg_advisory_lockam 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
CALLlaufen 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-transactionmit beiden Dateien). Die Restgrenze teilt der Selbstbau mit den Frameworks: Statements wieCREATE INDEX CONCURRENTLYvertragen 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
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.
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.
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.
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.
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
- Datenbank-CI/CD mit PostgreSQL — der komplette Lebenszyklus vom Objekt-File bis zum automatischen Deploy — der Hub dieses Clusters.
- SQL-Schema deployen ohne Migrations-Tool — Verzeichnis-Konvention statt Flyway oder Liquibase — das Deploy-Modell, dessen Run-once-Lücke dieser Artikel schließt.
- NOT-NULL-Spalte nachträglich hinzufügen — das Expand/Contract-Muster für befüllte Tabellen — der häufigste Anwendungsfall für ein Run-once-Skript.
- GitHub Actions für Postgres-Deploys — mit Wegwerf-Datenbank als Qualitäts-Gate — der CI-Doppel-Deploy als automatischer Beweis des Skip-Pfads.
- Postgres-Tabellen-Konventionen — Naming, Keys und Audit-Spalten — die Objekt-Schreibweise der Beispiele.
- Das offene DI²-Starter-Kit auf GitHub — Tracking-Tabelle, Prozedur und Runner im Zusammenhang.