Migrations & Disaster Recovery
Forge generates SQL migrations from your models. It uses Go's Abstract Syntax Tree (go/ast) parser to read the declared models. It rebuilds the current schema from the migration files you already have, compares the two, and writes the difference as a numbered pair of SQL files.
Core Commands
# Generate a migration from model changes (writes nothing if nothing changed)
forge makemigrations <name> --auto
# Preview, then apply pending migrations
forge migrate up --dry-run
forge migrate up
# Show the applied version and pending migrations
forge migrate status
# Roll back the last applied migration
forge migrate rollback
# Compare applied migration files with their recorded checksums, and report dirty state
forge migrate recover --verify
# Recover from a failed migration (see "Recovering from a failed migration")
forge migrate force <version>
forge migrate recover --clean --version <version>
The Migration Workflow
- Modify your models: update fields, indexes, constraints, or relations in
models/. - Generate a migration: run
forge makemigrations add_status --auto. Forge writesmigrations/000002_add_status.up.sqland.down.sql, or printsNo changes detectedwhen the models match the existing migrations. - Review the SQL: migrations are plain SQL that you commit. Read every generated file before applying it (see Safe schema changes).
- Apply: run
forge migrate up. Forge records the applied version inschema_migrations(version,dirty) and the SHA-256 of each applied up and down file inforge_migration_checksums.
Safe Schema Changes
Generated migrations are proposals, and an operator should review each one before it reaches a database with data in it.
- Renames are not detected. Renaming a field or a table in a model produces a
DROPof the old name and anADDof the new one, which loses the data. Write the rename by hand instead (see Renaming a column or table). - Destructive statements. Treat
DROP TABLE,DROP COLUMN, and type changes (ALTER COLUMN ... TYPE) as data loss until proven otherwise.forge migrate lintflagsDROP TABLEandTRUNCATE, but notDROP COLUMN, so read the SQL yourself. - Constraints check existing rows. Adding a foreign key, a
CHECK, aUNIQUEconstraint, or aNOT NULLcolumn without a default fails if existing rows violate it. Clean up the data first, in its own migration. - Hand-written DDL becomes part of the recorded schema.
makemigrationsreads every migration file, so a column or constraint that you add by hand but do not declare in a model shows up as a drop in the next generated migration. - Never edit an applied migration. Write a new one. Editing an applied file changes its
checksum, and
forge migrate upthen refuses to run (see below).
Backups and restores
- Take a backup immediately before applying migrations to a production database, for
example
pg_dump --format=custom --file=before-000007.dump "$DATABASE_URL". - Down migrations are not backups. Rolling back a
DROP COLUMNrecreates the column empty, so the only way back to the data is a restore. - Restore into a new database (
createdb app_restore && pg_restore --dbname=app_restore before-000007.dump), then check it.schema_migrationsandforge_migration_checksumsare restored with the data, soforge migrate statusshows the version the backup was taken at, andforge migrate recover --verifychecks the files you have against it. Runforge migrate upto apply the migrations that came after the backup.
Renaming a column or table
makemigrations reads a hand-written ALTER TABLE .. RENAME COLUMN and
ALTER TABLE .. RENAME TO back from the migration files. The indexes, constraints, and
foreign keys that refer to the renamed column or table follow it, as they do in the
database. Rename in one migration, and change the model to match before you generate
again:
-
Create an empty migration:
forge makemigrations rename_isbn --empty. -
Write the rename in the up file and its reverse in the down file:
-- 000004_rename_isbn.up.sqlALTER TABLE books RENAME COLUMN isbn TO code;ALTER TABLE authors RENAME TO writers;-- 000004_rename_isbn.down.sqlALTER TABLE writers RENAME TO authors;ALTER TABLE books RENAME COLUMN code TO isbn; -
Update the model: the field name,
Meta.TableName, and the fields and conditions of anyMeta.IndexesandMeta.Constraintsthat name the column. Index and constraint names do not change. -
Run
forge makemigrations check --auto. It printsNo changes detected.
On PostgreSQL, a foreign key constraint keeps its name when its table or column is
renamed, but Forge names it fk_<table>_<column> from the current names, and drops it by
that name later. When the renamed table or column has its own foreign key, rename the
constraint in the same migration, for example
ALTER TABLE books RENAME CONSTRAINT fk_books_author_id TO fk_books_writer_id;. SQLite
(3.25 or later) runs the same RENAME statements; its foreign keys have no name.
For a rolling deploy, where old and new code run side by side, rename in steps instead:
add the new field, copy the data in a hand-written migration (for example
UPDATE posts SET headline = title;), deploy code that uses the new field, then remove
the old field.
Changes makemigrations cannot generate
makemigrations stops with an error, and writes nothing, when a model change has no
generated SQL. It does not write an empty or comment-only migration that would be proposed
again on every run.
- PostgreSQL column changes. A column's type,
NOT NULL, and default (Default,DBDefault,AutoNow,AutoNowAdd) are changed withALTER COLUMN. A change to an existing column'sUnique(), primary key,AutoIncrement, generated expression, orDBColumnname is not generated. For uniqueness on an existing table, declare aUNIQUEconstraint inMeta.Constraintsinstead of addingUnique()to the field. Otherwise, revert the model change. - SQLite column, foreign key, and constraint changes. SQLite's
ALTER TABLEcan add, drop, and rename a column, and rename a table, but it cannot change a column or add or drop a foreign key or constraint of an existing table. That takes a table rebuild, whichmakemigrationsdoes not generate: it fails withmodify column <table>.<column> is not supported on SQLite without rebuilding the table(oradd constraint,add foreign key, and so on). Write the rebuild by hand, as described below. This is also the way forward for an existing SQLite project whose models declareMeta.Constraintsor foreign keys that older Forge versions skipped without an error.
Rebuilding a SQLite table by hand
A hand-written rebuild follows the steps of the
SQLite documentation: create the
new table under a temporary name, copy the rows, drop the old table, rename the new one,
and re-create the old table's indexes. makemigrations reads the rebuild back from the
migration file, so write the new table exactly as the model now declares it, and the next
run writes nothing.
-
Create an empty migration:
forge makemigrations rebuild_books --empty. -
In the up file, rebuild the table with its new definition. Start from the
CREATE TABLEin the migration that created the table, and add the new column definition, foreign key, or constraint:CREATE TABLE books_new ("id" INTEGER PRIMARY KEY AUTOINCREMENT,"author_id" INTEGER NOT NULL,"pages" INTEGER DEFAULT 1,"isbn" TEXT,FOREIGN KEY (author_id) REFERENCES authors (id) ON DELETE CASCADE ON UPDATE NO ACTION,CONSTRAINT books_pages_positive CHECK (pages > 0));INSERT INTO books_new ("id", "author_id", "pages", "isbn")SELECT "id", "author_id", "pages", "isbn" FROM books;DROP TABLE books;ALTER TABLE books_new RENAME TO books;CREATE INDEX IF NOT EXISTS books_isbn_idx ON books ("isbn"); -
In the down file, rebuild the table the same way with its previous definition.
-
Run
forge makemigrations check --auto. It printsNo changes detected.
Check the data before you apply the rebuild: the copy fails if an existing row violates
a new NOT NULL, CHECK, or UNIQUE constraint.
forge migrate runs each SQLite migration in a transaction, and SQLite ignores
PRAGMA foreign_keys inside a transaction. If your SQLite DSN turns foreign keys on (for
example ?_foreign_keys=on), dropping a table that other tables reference runs their
ON DELETE actions, so CASCADE deletes and SET NULL clears the referencing rows. Apply
a migration that rebuilds a referenced table with a DSN that leaves foreign keys off,
then run PRAGMA foreign_key_check; to confirm that every reference still resolves.
Rebuilding a table that no other table references, such as books above, is not affected.
Recovering from a Failed Migration
When a statement in a migration fails, forge migrate up stops with the database error and
marks that version dirty. Every further up or rollback refuses to run until you resolve
it.
-
Inspect:
forge migrate recoverprints the dirty version and both ways out. -
If the failed migration left no changes, re-apply it. This is the usual case on PostgreSQL, where each migration file runs as one transaction, so a failure rolls back the statements that had already run. Confirm that the database does not contain the migration's changes, fix the file, and re-apply it:
forge migrate force <previous version> # records the last good version, cleanforge migrate up # runs the fixed migration and records its checksumA file that contains its own
COMMITcan leave partial changes. Undo them by hand before you force the previous version. -
If you completed the migration's changes by hand, mark it applied:
forge migrate recover --clean --version <version>. Then runforge migrate baseline --adoptto record its checksum.
Do not mark a rolled-back version clean. That records the migration as applied when none of its changes are in the database.
Checksum mismatches
forge migrate recover --verify compares each applied file with its recorded checksum and
lists a changed one as <version> mismatched. It also exits non-zero. While any file is
mismatched or missing, forge migrate up refuses to run and names the version. Restore the
file from version control, and write a new migration for the change you intended. For
databases migrated before checksums were recorded, forge migrate baseline --adopt records
the current files. It never overwrites a mismatch.
What Is Verified
The release gate runs TestPostgresSchemaLifecycle (in tests/pkg_migrations) against
PostgreSQL through the same commands used above. The test covers these steps:
- It generates and applies an initial schema, then seeds rows.
- It adds a column with a default, a related table with a foreign key, and
CHECKandUNIQUEconstraints. It checks the result ininformation_schemaand confirms that the seeded rows are unchanged. - It regenerates the unchanged models and confirms that nothing is written, and that
schema_migrationsandforge_migration_checksumsmatch the applied files. - It applies a migration that fails after its first statement ran, and confirms that the
version is dirty and the partial change was rolled back. It then recovers with
force+up. - It edits an applied migration and confirms that the mismatch is reported and blocks
up.
To reproduce, run the following against a PostgreSQL role that can create databases (the
test creates and drops a lifecycle_<n> database):
cd tests
FORGE_REQUIRE_DB=1 \
FORGE_TEST_DATABASE_URL='postgres://forge_test:forge_test@localhost:5432/forge_test?sslmode=disable' \
go test -count=1 -v -run 'TestPostgresSchemaLifecycle|TestChecksumBaseline' ./pkg_migrations/
# Generation stability and the DSL contract (no database needed)
cd ..
go test -count=1 -run 'Regeneration|InitialMigration|ChangedForeignKey' ./db/migrate/generate/
go test -count=1 -run 'DSL' ./codegen/
Without FORGE_REQUIRE_DB=1 the PostgreSQL tests skip when no database is reachable. With
it set, they fail instead.
Applying migrations to SQLite is experimental, and the release gate does not cover it.
TestMigrationApplySQLite is the one test the gate allows to skip. On SQLite, a new
table's foreign keys and Meta.Constraints are declared inside its CREATE TABLE and read
back from there, so regenerating unchanged models writes nothing. SQLite cannot add, drop
or change a foreign key or constraint of an existing table without rebuilding it, and
makemigrations does not write that rebuild: it stops with an error instead. The same
applies to a changed column, because SQLite has no ALTER COLUMN. See
Changes makemigrations cannot generate.
Best Practices for Production
- Run the checks in CI/CD: run
forge migrate statusandforge migrate recover --verifyin your deployment pipeline before application pods start. - Review, back up, then apply: generate and review migrations in development, commit
them, back up production, and only then run
forge migrate up. - One concern per migration: keep data backfills in their own hand-written migrations, separate from schema changes. That keeps failures small and recovery clear.
Next Steps
- Models & Schema DSL: Define fields and constraints that power migrations.
- Quickstart Guide: Run your first migration in 60 seconds.