Das Schema steht — und jetzt? Der Reflex heißt Flyway oder Liquibase. Aber ein Migrations-Tool ist für ein kleines, datenbank-zentriertes Projekt oft mehr Apparat, als die Aufgabe verlangt: eine eigene Versions-Tabelle, eine Runtime-Abhängigkeit, ein Format, an das man sich bindet. Es geht auch anders. Ein SQL-Schema deployen ohne Migrations-Tool heißt: ein paar Konventionen, idempotente Skripte und ein kleiner Bash-Runner — versioniert im Repo, ohne Lock-in.
Dieser Artikel zeigt das an einem laufenden Beispiel: dem Datenbank-Teil eines offenen Claude-Code-Starter-Kits, dessen db/-Ordner genau so aufgebaut ist.
Das Wichtigste vorab:
- Das Verzeichnis plus die Datei-Nummerierung ist die Quelle der Wahrheit — kein zentrales
deploy.sql. - Idempotente, konvergente Skripte (
CREATE … IF NOT EXISTS,ALTER TABLE … ADD COLUMN IF NOT EXISTS,CREATE OR REPLACE) ersetzen Up-Migrationen — auch für spätere Änderungen an bestehenden Tabellen. - Run-once-Transitions in
predeploy/postdeployerledigen datenabhängige Schritte wie Backfills — genau einmal pro Datenbank, per Prüfsumme abgesichert. - Zwei kleine Log-Tabellen (
schema_apply_log,schema_change_log) ersetzen die Versions-Tabelle eines Migrations-Tools. - Die CI beweist die Idempotenz, indem sie zweimal deployt — und wo ein Tool trotzdem die bessere Wahl ist.
Voraussetzung: Postgres und psql; das Beispiel nutzt Postgres 17, der Ansatz gilt praktisch ab Postgres 14. Die gezeigten Bausteine stammen aus dem offenen DI²-Starter-Kit auf GitHub.
Inhalt
- Das Problem mit „einfach ein Migrations-Tool“
- Die Konvention: Verzeichnis und Nummerierung als Quelle der Wahrheit
- Idempotenz statt Up-Migrationen
- Run-once-Transitions: Backfills und andere datenabhängige Schritte
- Der schema_apply_log — die Versions-Tabelle in klein
- Der Apply-Runner
- In der CI absichern
- Die Rules mitwachsen lassen
- Wann ein echtes Migrations-Tool die bessere Wahl ist
- FAQ
- Verwandte Artikel
Das Problem mit „einfach ein Migrations-Tool“
Flyway, Liquibase und Alembic lösen ein echtes Problem: Sie führen Buch darüber, welche Änderung in welcher Reihenfolge auf welche Umgebung angewendet wurde. Dafür bringen sie eine Versions-Tabelle mit, eine feste Reihenfolge über nummerierte oder datierte Migrationsdateien und Prüfsummen, die verhindern, dass eine schon angewendete Datei nachträglich verändert wird.
Der Preis dafür ist eine dauerhafte Abhängigkeit: ein weiteres Werkzeug im Stack, ein Dateiformat, an das der Code gebunden ist, und im Fall der JVM-Tools eine Runtime, die mitlaufen muss. Für eine große Anwendung mit vielen Umgebungen und einem ganzen Team ist das der richtige Kompromiss. Für ein Solo-Projekt oder ein kleines, datenbank-zentriertes Repo ist es Overhead — und Lock-in für einen Nutzen, den man mit wenig Eigenbau ersetzen kann.
Der Reiz eines Migrations-Tools lässt sich auf drei Zutaten herunterbrechen: Reihenfolge, Idempotenz und eine Historie. Alle drei bekommt man auch mit Konvention statt Werkzeug — und die Prüfsummen-Sperre sogar dazu, an genau der Stelle, an der sie wirklich zählt. Zur Einordnung: Der Ansatz ersetzt damit bewusst nur den Teil eines Migrations-Tools, den ein kleines Projekt tatsächlich braucht — was Werkzeuge darüber hinaus leisten, kommt im letzten Abschnitt ehrlich auf den Tisch.
Die Konvention: Verzeichnis und Nummerierung als Quelle der Wahrheit
Statt einer wachsenden Liste von Migrationsdateien beschreibt der db/-Baum den Soll-Zustand des Schemas — ein Objekt pro Datei, sortiert nach Objekttyp:
db/schemas/app/
├── predeploy/
├── tables/ 001.customer.sql
│ 002.order.sql
│ 003.schema_apply_log.sql
│ 004.schema_change_log.sql
├── policies/
├── functions/ 000.fn_is_null_or_empty.sql
├── procedures/ 001.sp_ins_customer.sql
├── trigger/ 000.tf_set_modified.sql
│ 001.tr_u_customer.sql
├── views/ 001.vw_customer_overview.sql
├── data/ 001.seed.sql
└── postdeploy/ 202607050900.backfill-customer-notes.sql
Zwei Regeln machen daraus eine verlässliche Quelle der Wahrheit:
- Die Nummerierung ist ein Tabellengruppen-Indikator, keine globale Sequenz. Alle Objekte, die zu einer Tabelle gehören — die Tabelle selbst, ihre Prozeduren, ihr Trigger —, teilen sich dieselbe
NNN. So bleiben zusammengehörige Dinge beisammen, und eine neue Tabelle bricht die Nummerierung der bestehenden nicht. Inpredeployundpostdeploygilt stattdessen ein Zeitstempel-Präfix (YYYYMMDDHHMM) — dort zählt die Chronologie, nicht die Tabellengruppe. - Die Ladereihenfolge ist fest:
predeploy → tables → policies → functions → procedures → trigger → views → data → postdeploy. Views kommen nach den Funktionen, auf die sie zugreifen; die Seed-Daten kommen fast zuletzt. Genau genommen muss die Reihenfolge nur die Referenzen auflösen, die zurCREATE-Zeit geprüft werden — eine View auf eine Funktion etwa. PL/pgSQL-Körper werden erst zur Laufzeit aufgelöst; eine Prozedur darf deshalb problemlos eine View referenzieren, die in der Reihenfolge später kommt.
Der Baum enthält damit zwei Datei-Arten: Objekt-Dateien in den Sektionen dazwischen — idempotent, sie beschreiben den Soll-Zustand — und Transition-Skripte in den beiden Sonder-Slots am Anfang und Ende, die genau einmal pro Datenbank laufen. Die nächsten zwei Abschnitte nehmen sich beide der Reihe nach vor.
Der Runner (weiter unten) läuft genau diese Sektionen in dieser Reihenfolge ab, innerhalb jeder Sektion sortiert nach dem Präfix. Damit ist die Reihenfolge — die erste Zutat des Migrations-Tools — allein durch die Verzeichnisstruktur bestimmt.
Idempotenz statt Up-Migrationen
Ein Migrations-Tool wendet jede Datei genau einmal an. Der konventionsbasierte Ansatz kehrt das um: Jedes Skript darf beliebig oft laufen und führt immer zum selben Ergebnis. Damit entfällt für den gesamten Objekt-Bestand die Buchführung „welche Datei lief schon“ — man wendet einfach immer alle an.
Möglich machen das vier SQL-Konstrukte direkt in den Objekt-Dateien: CREATE TABLE IF NOT EXISTS, CREATE OR REPLACE für Funktionen und Prozeduren, DROP … IF EXISTS gefolgt von ADD für Constraints, die sich nicht in-place ändern lassen — und ALTER TABLE … ADD COLUMN IF NOT EXISTS für Spalten, die nach dem ersten CREATE dazukommen.
1: CREATE TABLE IF NOT EXISTS app.customer
2: (
3: id bigint NOT NULL GENERATED ALWAYS AS IDENTITY
4: ,name varchar(200) NOT NULL
5: ,is_active boolean NOT NULL DEFAULT true
6:
7: ,created_on timestamptz NOT NULL DEFAULT now()
8: ,created_by varchar(100) NOT NULL
9: ,modified_on timestamptz NULL
10: ,modified_by varchar(100) NULL
11:
12: ,CONSTRAINT pk_customer PRIMARY KEY (id)
13: ,CONSTRAINT chk_customer_name CHECK (length(trim(name)) > 0)
14: );
15:
16: ALTER TABLE app.customer DROP CONSTRAINT IF EXISTS uq_customer_name;
17: ALTER TABLE app.customer ADD CONSTRAINT uq_customer_name UNIQUE (name);
18:
19: -- Konvergente Evolution: Spalten, die nach dem ersten CREATE dazukamen
20: ALTER TABLE app.customer ADD COLUMN IF NOT EXISTS notes varchar(500) NULL;
Zeile 1 legt die Tabelle nur an, wenn sie fehlt. Die Zeilen 16 und 17 setzen den Unique-Constraint zurück und neu — das ist idempotent, weil ein zweiter Lauf den Constraint erst entfernt und dann identisch wieder anlegt. Kostenlos ist das allerdings nicht: Der erneute ADD CONSTRAINT baut den Unique-Index bei jedem Deploy neu auf und sperrt die Tabelle für diese Zeit. Bei den meisten Tabellen fällt das nicht ins Gewicht; bei sehr großen Tabellen oder in hochverfügbaren Systemen sollte man das bewusst abwägen. Zwei Details noch, die den Skripten Disziplin geben: Die Audit-Spalten created_on/created_by/modified_on/modified_by (Zeilen 7–10) sind auf jeder Tabelle gleich, und der Primärschlüssel nutzt GENERATED ALWAYS AS IDENTITY (Zeile 3) statt der älteren serial-Schreibweise.
Zeile 20 ist der wichtigste Baustein, denn sie beantwortet die Frage, an der konventionsbasierte Ansätze sonst enden: Wie kommt eine Änderung in eine Tabelle, die es schon gibt? CREATE TABLE IF NOT EXISTS fasst eine bestehende Tabelle nämlich nicht mehr an — eine später einfach in den CREATE-Block geschriebene Spalte käme auf einer Umgebung, die die Tabelle schon hat, nie an; das IF NOT EXISTS überspringt die Tabelle komplett. Deshalb wandern später ergänzte Spalten nicht in den CREATE-Block, sondern als idempotentes ALTER TABLE … ADD COLUMN IF NOT EXISTS ans Datei-Ende (Zeilen 19 und 20). Die Objekt-Datei beschreibt so den Soll-Zustand und konvergiert jede Umgebung dorthin: Ein frischer Deploy legt die Tabelle komplett an, eine Bestandsumgebung bekommt nur die fehlende Spalte dazu — beide landen bei derselben Form, ohne dass Daten verloren gehen. Spalten sind dabei nur der häufigste Fall: Jede Objektart braucht ihr eigenes konvergentes Muster — Constraints das gezeigte drop-then-add, Funktionen und Prozeduren das CREATE OR REPLACE. Entscheidend ist immer dieselbe Eigenschaft: Jedes Statement der Datei muss auf jeder Umgebung gefahrlos wiederholbar sein.
Was die Objekt-Datei nicht ausdrücken kann, ist datenabhängige Arbeit: die neue Spalte für Bestandszeilen befüllen, Daten vor einem destruktiven Umbau beiseitelegen, ein SET NOT NULL, das erst nach dem Backfill gelten darf. Solche Schritte dürfen gerade nicht bei jedem Deploy laufen — sie brauchen das Gegenteil von Idempotenz: genau einmal pro Datenbank. Dafür gibt es die Run-once-Transitions.
Run-once-Transitions: Backfills und andere datenabhängige Schritte
Die Ladereihenfolge beginnt und endet mit zwei Sonder-Slots: predeploy läuft vor allen Objekt-Dateien, postdeploy nach ihnen. Dort liegen Transition-Skripte — mit Zeitstempel statt Tabellengruppen-Nummer benannt und chronologisch sortiert. Ein predeploy-Skript legt zum Beispiel Daten beiseite, bevor ein Umbau sie anfasst; ein postdeploy-Skript befüllt eine Spalte, die es erst seit diesem Deploy gibt. So sieht der Backfill für die notes-Spalte aus dem vorigen Abschnitt aus:
1: -- postdeploy/202607050900.backfill-customer-notes.sql
2: UPDATE app.customer
3: SET
4: notes = 'backfilled: pre-existing row'
5: WHERE
6: notes IS NULL;
Der WHERE-Guard (Zeile 6) hat zwei Aufgaben: Er überschreibt nie einen Wert, den ein Nutzer inzwischen gesetzt hat — und er macht das Skript greenfield-sicher. Denn eine frisch aufgesetzte Datenbank durchläuft dieselben Transitions ebenfalls, einmal in chronologischer Reihenfolge; auf einem leeren Schema muss so ein Skript schlicht als No-op durchlaufen.
Anders als die Objekt-Dateien laufen Transitions genau einmal pro Datenbank. Das Buch darüber führt eine zweite kleine Tracker-Tabelle:
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: );
12:
13: ALTER TABLE app.schema_change_log DROP CONSTRAINT IF EXISTS uq_schema_change_log_filename;
14: ALTER TABLE app.schema_change_log ADD CONSTRAINT uq_schema_change_log_filename UNIQUE (filename);
Der Dateiname ist der Run-once-Schlüssel (Unique-Constraint, Zeilen 13 und 14), die SHA-256-Prüfsumme zum Anwendungs-Zeitpunkt (Zeile 5) der Unveränderlichkeits-Wächter. Vor jeder Transition unterscheidet der Deploy drei Fälle:
- Noch nicht angewendet → ausführen und mit Dateiname, Prüfsumme und Git-SHA protokollieren.
- Angewendet, Prüfsumme unverändert → überspringen.
- Angewendet, Prüfsumme verändert → Abbruch. Eine angewendete Transition-Datei ist unveränderlich; eine Korrektur ist eine neue Datei.
Damit holt sich der Ansatz die Prüfsummen-Sperre eines Migrations-Tools genau dort zurück, wo sie zählt: bei Skripten, die nie doppelt laufen dürfen. Die angewendeten Dateien bleiben im Repo liegen — das Tracking sorgt dafür, dass sie nie erneut laufen.
Der wichtigste Anwendungsfall ist die NOT NULL-Spalte auf einer befüllten Tabelle, in drei Schritten (Expand/Contract):
- Objekt-Datei: die Spalte nullable per
ADD COLUMN IF NOT EXISTSergänzen. - Postdeploy-Skript: erst der Backfill, dann das
SET NOT NULLam Ende desselben Skripts — stünde dasSET NOT NULLin der Objekt-Datei, liefe es auf einer Bestandsumgebung vor dem Backfill und schlüge fehl. - Nachziehen: Sobald die Transition auf allen Umgebungen gelaufen ist, das
SET NOT NULLals idempotentesALTERin die Objekt-Datei übernehmen — dann beschreibt sie den Soll-Zustand wieder vollständig, und ein frischer Deploy hängt nicht am Effekt eines alten Transition-Skripts.
Der schema_apply_log — die Versions-Tabelle in klein
Um die dritte Zutat des Migrations-Tools — die Historie — kümmert sich eine Geschwister-Tabelle des schema_change_log: der schema_apply_log. Auch er ist append-only, protokolliert aber nicht pro Transition-Datei, sondern eine Zeile pro Deploy-Lauf — die jüngste Zeile dokumentiert den letzten erfolgreichen Deploy der Umgebung.
1: CREATE TABLE IF NOT EXISTS app.schema_apply_log
2: (
3: id bigint NOT NULL GENERATED ALWAYS AS IDENTITY
4: ,db_version varchar(50) NOT NULL
5: ,git_sha varchar(64) NOT NULL
6: ,environment varchar(10) NOT NULL
7: ,note varchar(500) NULL
8: ,applied_on timestamptz NOT NULL DEFAULT now()
9: ,applied_by varchar(100) NOT NULL DEFAULT current_user
10:
11: ,CONSTRAINT pk_schema_apply_log PRIMARY KEY (id)
12: );
Der git_sha (Zeile 5) verankert jeden Lauf an einem konkreten Commit — die Frage „welches Schema liegt auf Umgebung X?“ beantwortet ein SELECT auf die jüngste Zeile, und ein Drift-Check vergleicht diesen SHA mit dem, was der Code meldet. Das trägt, solange ausschließlich über den Runner deployt wird — manuelle Hotfixes an der Datenbank vorbei tauchen hier naturgemäß nicht auf. applied_by (Zeile 9) fällt auf current_user zurück, weil der Deploy-Akteur die Verbindungsrolle ist, nicht ein Anwendungsnutzer.
Den Insert kapselt das Starter-Kit in eine kleine Prozedur, die Version, Git-SHA und Umgebung als Parameter durchreicht — im Kern ist es aber nur ein INSERT pro Lauf:
1: INSERT INTO app.schema_apply_log
2: (
3: db_version
4: ,git_sha
5: ,environment
6: ,note
7: )
8: VALUES
9: (
10: '1.0.0'
11: ,'a1b2c3d'
12: ,'local'
13: ,'initial deploy'
14: );
Was der schema_apply_log bewusst nicht kann: Er erzwingt nichts — er ist ein Audit-Log, keine Sperre. Die einzige harte Sperre des Ansatzes sitzt im schema_change_log, und sie gilt nur für Transition-Dateien. Für die Objekt-Dateien bleibt es beim Prinzip: Die Verzeichnis-Konvention gilt, weil das Team sie einhält, nicht weil ein Werkzeug sie erzwingt.
Der Apply-Runner
Der Runner ist ein Bash-Skript mit zwei Laufwegen. Die Objekt-Sektionen sammelt er in fester Reihenfolge ein und gibt sie in einem psql-Aufruf mit ON_ERROR_STOP=1 weiter — sie sind idempotent, also läuft schlicht immer alles, und der erste Fehler bricht den Lauf ab. Die Transitions in predeploy und postdeploy laufen dagegen Datei für Datei, jede mit der Run-once-Prüfung aus dem vorigen Abschnitt.
1: set -e
2:
3: SCHEMA_DIR="db/schemas/app"
4: SECTIONS=(tables policies functions procedures trigger views data)
5: PSQL=(psql -v ON_ERROR_STOP=1)
6:
7: # Run-once-Transition: neu -> anwenden + protokollieren; unveraendert ->
8: # ueberspringen; veraendert -> Abbruch (angewendete Dateien sind unveraenderlich).
9: run_once() {
10: local file="$1" name checksum applied
11: name="app/$(basename "$(dirname "$file")")/$(basename "$file")"
12: checksum="$(sha256sum "$file" | cut -d' ' -f1)"
13:
14: applied="$("${PSQL[@]}" -tA -v "fname=$name" -f - <<'SQL'
15: SELECT checksum FROM app.schema_change_log WHERE filename = :'fname';
16: SQL
17: )"
18:
19: if [ -z "$applied" ]; then
20: echo "applying $name"
21: "${PSQL[@]}" -f "$file"
22: "${PSQL[@]}" -v "fname=$name" -v "sum=$checksum" -f - <<'SQL'
23: INSERT INTO app.schema_change_log (filename, checksum, git_sha)
24: VALUES (:'fname', :'sum', 'unknown');
25: SQL
26: elif [ "$applied" = "$checksum" ]; then
27: echo "$name skipped (already applied)"
28: else
29: echo "Abbruch: $name wurde nach dem Anwenden veraendert." >&2
30: exit 1
31: fi
32: }
33:
34: # 1. Tracker zuerst sicherstellen: predeploy laeuft vor tables -- auf einer
35: # frischen Datenbank gaebe es den schema_change_log sonst noch nicht.
36: "${PSQL[@]}" -f "$SCHEMA_DIR/tables/004.schema_change_log.sql"
37:
38: # 2. predeploy: Datei fuer Datei, run-once.
39: for f in "$SCHEMA_DIR"/predeploy/*.sql; do
40: [ -e "$f" ] || continue
41: run_once "$f"
42: done
43:
44: # 3. Objekt-Sektionen: gebatcht in einem psql-Aufruf.
45: files=()
46: for section in "${SECTIONS[@]}"; do
47: dir="$SCHEMA_DIR/$section"
48: [ -d "$dir" ] || continue
49: for f in "$dir"/*.sql; do
50: [ -e "$f" ] || continue
51: files+=(-f "$f")
52: done
53: done
54: "${PSQL[@]}" "${files[@]}"
55:
56: # 4. postdeploy: Datei fuer Datei, run-once.
57: for f in "$SCHEMA_DIR"/postdeploy/*.sql; do
58: [ -e "$f" ] || continue
59: run_once "$f"
60: done
Ein Detail verdient einen zweiten Blick: Der Runner stellt die Tracker-Tabelle als Allererstes sicher (Schritt 1) — predeploy läuft vor tables, und auf einer frischen Datenbank gäbe es den schema_change_log sonst noch gar nicht. Ein Henne-Ei-Problem, das man besser im Runner löst als in jeder Transition-Datei. Die Prüfsummen-Werte gehen dabei als psql-Variablen in die Statements (:'fname'), nicht per String-Verkettung.
Wichtig ist die Trennung zweier Läufe, die oft verwechselt wird:
- Der Schema-Deploy (oben) verbindet als Schema-Owner und ist idempotent — beliebig oft wiederholbar.
- Das einmalige Cluster-Bootstrap (Datenbank, Extensions, Rollen, Schema, Grants anlegen) verbindet als Superuser und ist drop-and-recreate, also nicht idempotent. Es läuft einmal beim Aufsetzen einer Umgebung, nicht bei jedem Deploy.
Diese Trennung ist der Grund, warum der Schema-Deploy so entspannt wiederholbar bleibt: Er fasst nie die Datenbank oder die Rollen an, sondern nur die Objekte im Schema.
In der CI absichern
Der beste Beleg dafür, dass die Konvention hält und kein Bastel-Workaround ist: eine CI, die bei jedem Pull Request eine echte Datenbank hochfährt und das Schema anwendet — und danach ein zweites Mal deployt, um die Idempotenz zu beweisen.
1: services:
2: postgres:
3: image: postgres:17
4: env:
5: POSTGRES_PASSWORD: pw
6: steps:
7: - run: bash db/scripts/deploy.sh app local # 1. deploy
8: - run: bash db/tests/run.sh app local # 2. Objekt-Tests
9: - run: bash db/scripts/deploy.sh app local # 3. re-deploy = Idempotenz- und Skip-Check
Die Postgres-Instanz lebt nur für die Dauer des CI-Laufs: Sie startet als Service im Runner und verschwindet danach wieder, mit einem committeten Wegwerf-Passwort statt eines Secrets — dadurch läuft die CI auch aus Fork-Pull-Requests. Schritt 3 ist der eigentliche Trick: Läuft der Deploy zweimal ohne Fehler durch, ist die Idempotenz nicht behauptet, sondern gezeigt. Und der Doppel-Deploy prüft beides — die Idempotenz der Objekt-Dateien und den Run-once-Pfad der Transitions: Der zweite Lauf muss jede angewendete Transition als skipped (already applied) melden. Ein DDL-Skript, das beim zweiten Lauf stolpert, fällt hier auf — und nicht erst später auf einer Umgebung, die die Tabelle schon hatte.
Genau dieser Zweit-Lauf ist auch reproduzierbar: In einem Wegwerf-Container postgres:16-alpine das Schema zweimal hintereinander anwenden — der zweite Lauf meldet relation "customer" already exists, skipping für die Objekte, skipped (already applied) für die Transitions und endet mit Exit-Code 0.
Die Rules mitwachsen lassen
Die Konvention lebt nicht im Runner, sondern in den Rules-Dateien, die sie beschreiben — im Starter-Kit sind das die Regeln zu Verzeichnis-Aufbau, SQL-Formatierung und Apply-Ablauf, die Claude Code bei jeder Datenbank-Aufgabe lädt. Diese Dateien sind kein statisches Regelwerk, sondern ein lebendes Dokument.
Die Feedback-Schleife dazu ist einfach: Wenn am generierten Ergebnis etwas auszusetzen ist — die Formatierung passt nicht, eine Vorgehensweise soll ab jetzt immer gelten oder nur in einem bestimmten Fall —, dann korrigiert man nicht nur die eine Datei, sondern weist den Agenten an, die betreffende Rule gleich mit zu aktualisieren: „Nimm das in die Rule auf.“ So wandert jede Korrektur vom Einzelfall in die Konvention, und derselbe Punkt muss nie zweimal beanstandet werden.
Genau so sind auch die Regeln dieses Artikels entstanden: Die konvergente Evolution steht als eigene Konvention in der Tabellen-Rule des Kits — inklusive der Expand/Contract-Sequenz —, und die Unveränderlichkeit angewendeter Transition-Dateien ist in der Deployment-Rule festgehalten, damit sie bei jeder künftigen Schema-Änderung mitgilt. Beides waren zuerst Diskussionspunkte am konkreten Ergebnis, bevor sie zur Regel wurden. Was grundsätzlich in Rules gehört und was in Skills, klärt der Artikel Skills vs. Rules in Claude Code.
Wann ein echtes Migrations-Tool die bessere Wahl ist
Der ehrliche Teil. Die Reflex-Antwort „sobald echte Daten im Spiel sind, braucht es ein Migrations-Tool“ gilt so pauschal nicht: Konvergente Objekt-Dateien nehmen additive Änderungen mit, Run-once-Transitions decken Backfills, Datenumzüge und nachgelagerte Constraints ab — beides zusammen trägt auch Umgebungen mit Daten, die man nicht verwerfen darf. Es bleiben aber Fälle, in denen ein Tool die bessere Wahl ist:
- Rollback und Down-Migrationen. Der konventionsbasierte Ansatz kennt nur vorwärts — eine misslungene Änderung wird durch die nächste Änderung korrigiert, nicht durch einen Rückwärts-Schritt. Wer geordnete Down-Pfade braucht, etwa für Release-Rollbacks mit Schema-Rückbau, bekommt sie von Flyway oder Liquibase strukturiert geliefert.
- Orchestrierte Groß-Umbauten. Eine Spalte umbenennen oder löschen mit Datenerhalt, eine Tabelle aufteilen, ein Typwechsel mit langem Backfill, Zero-Downtime-Umbauten mit mehreren Zwischenzuständen — das lässt sich als Kette von Transitions ausdrücken, aber ab einer gewissen Komplexität sind Tool-Features wie Dry-Run, Change-Set-Orchestrierung und Berichte ihren Preis wert.
- Große Teams und Governance. Die Prüfsummen-Sperre gilt hier nur für Transition-Dateien; die Objekt-Dateien bleiben Konventions-Sache. Wo viele Beteiligte auf vielen Umgebungen arbeiten, mehrere aktive Branches dieselbe Datenbank treffen und man sich nicht auf die Konvention verlassen kann, erzwingt ein Tool die Disziplin vollständig.
- Der Stack bringt sein Tool schon mit. Wer ohnehin mit Prisma, Django oder Alembic arbeitet, hat ein Migrations-Modell im Stack — dann diesem folgen, statt parallel einen Eigenbau zu pflegen.
Der Punkt ist nicht, Migrations-Tools zu vermeiden, sondern sie bewusst zu wählen. Wer klein und datenbank-zentriert startet, gewinnt mit der Konvention Zeit und Unabhängigkeit — und wechselt, wenn das Projekt es verlangt, nicht reflexhaft am ersten Tag.
FAQ
Man beschreibt den Soll-Zustand als ein Objekt pro Datei, sortiert nach Objekttyp, und wendet die Dateien mit einem kurzen Runner-Skript in fester Reihenfolge an. Jedes Skript ist idempotent (CREATE … IF NOT EXISTS, CREATE OR REPLACE), sodass man einfach immer alle Dateien anwendet, statt Buch über einzelne Migrationen zu führen. Datenabhängige Einmal-Schritte wie Backfills übernehmen Run-once-Transition-Skripte; zwei kleine Log-Tabellen halten die Historie.
Zwei kleine append-only Tabellen. schema_apply_log protokolliert jeden Deploy-Lauf (Version, Git-SHA, Umgebung, Zeitpunkt) — die jüngste Zeile dokumentiert den letzten erfolgreichen Deploy. schema_change_log führt Buch über jede angewendete Transition-Datei: Der Dateiname ist der Run-once-Schlüssel, die Prüfsumme blockiert nachträgliche Änderungen an bereits angewendeten Dateien.
Mit CREATE TABLE IF NOT EXISTS, CREATE OR REPLACE für Funktionen und Prozeduren, DROP CONSTRAINT IF EXISTS gefolgt von ADD CONSTRAINT für Constraints — und ALTER TABLE … ADD COLUMN IF NOT EXISTS für Spalten, die nach dem ersten CREATE dazukommen. Testen lässt sich das, indem man dasselbe Skript zweimal hintereinander laufen lässt — der zweite Lauf muss ohne Fehler durchgehen.
Die Spalte kommt als idempotentes ALTER TABLE … ADD COLUMN IF NOT EXISTS in die Objekt-Datei — CREATE TABLE IF NOT EXISTS allein würde die bestehende Tabelle überspringen. Braucht die Spalte einen Backfill oder ein SET NOT NULL, übernimmt das ein Run-once-Skript in postdeploy: erst der Backfill, dann das SET NOT NULL am Ende desselben Skripts; später wandert das SET NOT NULL als idempotentes ALTER in die Objekt-Datei.
Anders als Flyway bringt der Ansatz kein eingebautes Deploy-Locking mit. In der Praxis serialisiert die CI: Deploys laufen nur über die Pipeline, und die erlaubt pro Umgebung genau einen Lauf gleichzeitig (in GitHub Actions über eine concurrency-Gruppe). Wer zusätzlich auf Datenbank-Seite absichern will, hält für die Dauer des Laufs einen Advisory-Lock (pg_advisory_lock) — spätestens hier ist aber der Punkt erreicht, an dem ein Tool mit eingebautem Locking die einfachere Antwort ist.
Das Prinzip — Verzeichnis als Quelle der Wahrheit, idempotente Skripte, Run-once-Transitions, die Log-Tabellen — überträgt sich, aber die Syntax nicht eins zu eins: SQL Server kennt kein CREATE TABLE IF NOT EXISTS, dort prüft man stattdessen mit IF OBJECT_ID(…) IS NULL oder IF NOT EXISTS (SELECT … FROM sys.objects); für Spalten übernimmt IF COL_LENGTH(…) IS NULL die Rolle von ADD COLUMN IF NOT EXISTS. Ladereihenfolge und Run-once-Mechanik bleiben gleich.
Verwandte Artikel
- Ein Claude-Code-Projekt mit Entwicklungsworkflow und Datenbank aufsetzen — der Überblick, aus dem dieser Datenbank-Teil stammt.
- Maximal-Vorlage statt leeres Repo — ein Setup, das sich per /init selbst zuschneidet
- Skills vs. Rules in Claude Code — was automatisch lädt und was auf Abruf kommt
- Postgres-Tabellen-Konventionen — Naming, Keys und Audit-Spalten — die Objekt-Schreibweise, die dieser Artikel voraussetzt.
- Das offene DI²-Starter-Kit auf GitHub — der vollständige
db/-Ordner mit Runner, Tests und CI.