Adopting migrations on an existing database¶
This how-to moves a database that connect(url, migrate_updates=True) or an Alembic chain built onto Migrations, without running any DDL on it and without losing a row. The tool is a baseline: it records migrations as applied on a database that already has their schema. It runs nothing, and it records only after checking.
The four steps¶
- Bring the database to the old door's head. One last start with
migrate_updates=True, oralembic upgrade head, so it matches the models you are about to describe. -
Generate the first migration from the models.
0001comes from the models, never from the database:newprints the files it wrote:default · migrations ok Wrote migrations/0001_create_author/ 01_schema.up.postgres.sql schema step, up (postgres) 01_schema.down.postgres.sql schema step, down (postgres) 01_schema.up.sqlite.sql schema step, up (sqlite) 01_schema.down.sqlite.sql schema step, down (sqlite) ir.json snapshot 1 new model AuthorCommit it. Run
ferro migrate upon a fresh database and you get the same schema the old door built. -
Baseline each existing database. Point
DATABASE_URLat it and record the migrations:baselinechecks the database against the migrations, then records them:default · sqlite:///./app.db recorded 0001_create_author as baseline (1 step) baseline checked what `ferro migrate drift` checks: objects ferro does not own (functions, triggers) were not comparedWithout a target it records every migration in the directory;
ferro migrate baseline 0001stops at0001. Data steps are listed and recorded, not run. -
Stop using the old door on that database. Remove the auto-migrate flags (they are now refused on it), or stop generating Alembic revisions for Ferro's tables (autogenerate now refuses them). From here,
ferro migrate newandferro migrate up.
Until it is baselined, ferro migrate up refuses to run 0001 over tables that already exist:
default · sqlite:///./app.db
This database has no ferro migration records, but table "author" from 0001 already exists. If it was built by auto-migrate or Alembic, run "ferro migrate baseline 0001". up will not run 0001 over it.
Baseline refusals on drift¶
Baseline compares the database with the target migration's schema snapshot exactly as ferro migrate drift does. On any difference, ferro migrate baseline 0001 records nothing, prints each one and exits 4:
default · sqlite:///./app.db
drift against 0001_create_author:
author.slug column is extra
nothing was recorded
Each line is a piece of work, and no flag records past them: a baseline recorded over a difference would leave the next migration to run against a schema it was not written for. Either the database is not where you think it is, or the migration does not describe it. Fix whichever is wrong and baseline again:
- The database is ahead of the target (here, it already has the
sluga later migration adds): baseline a later migration instead,ferro migrate baselinewith no target orferro migrate baseline 0002. - The database is behind the models: finish step 1 (one more
migrate_updatesstart oralembic upgrade head). - Someone changed the database by hand: put it back, or declare the change on the models and generate a migration, then baseline that one.
-
A column has a server default nobody declared (a foreign default; Ferro persists none of its own):
On SQLite a literal default is not reported; a default that is an expression is, as here:
default · sqlite:///./app.db drift against 0001_create_author: author.slug has default lower('unknown') in the database, none declared nothing was recordedDrop it, then baseline again. Either give the old door one last start that removes what the models never declared,
connect(url, migrate_destructive=True), or drop it by hand on Postgres:The column then has no default, and the next baseline records. On SQLite neither works: SQLite drops a default only by rebuilding the table, so rebuild it without the
DEFAULTby hand (create a copy, copy the rows across, drop the table, rename the copy).
Three things are never a difference:
- Tables the snapshot does not declare:
alembic_version, another application's tables. - The two server defaults Ferro leaves itself: a
serialkey's sequence on Postgres (recognised by the sequence the column owns, so a renamed table's still counts), and on SQLite a literal default on aNOT NULLcolumn, whichmigrate_updateskeeps when it adds a required column. - Objects Ferro does not own, such as functions and triggers. They are not compared, and the report says so.
Databases at different points¶
Every database is baselined at the migration it actually matches. A production database at the head takes ferro migrate baseline; a staging copy restored from an older dump takes ferro migrate baseline 0001, then ferro migrate up applies the rest:
With STAGING_URL naming the staging copy (sqlite:staging.db here), ferro migrate baseline 0001 --url "$STAGING_URL" records the one migration it matches:
default · sqlite:///./staging.db
recorded 0001_create_author as baseline (1 step)
baseline checked what `ferro migrate drift` checks: objects ferro does not own (functions, triggers) were not compared
ferro migrate up --url "$STAGING_URL" then applies the rest:
ferro migrate status on a production database baselined at the head shows both migrations as applied (baseline):
default · sqlite:///./app.db
ok 2 applied main._ferro_migrations
ok 0001_create_author applied (baseline) 2026-10-15 04:30Z
ok 0002_author_slug applied (baseline) 2026-10-15 04:30Z
The staging copy shows 0001_create_author as applied (baseline) and 0002_author_slug as applied. Baseline is refused on a database that already has migration records; status says where it stands.
Coming from Alembic¶
The steps are the same, with Alembic as the old door:
- Step 1 is
alembic upgrade head. - In step 4, remove
get_metadata()fromenv.py'starget_metadataonce every database is baselined. Ferro's migrations ignorealembic_version; drop it when no database needs the Alembic chain any more. - A project whose Alembic chain also manages its own SQLAlchemy tables keeps that chain for them: drop
get_metadata()fromtarget_metadataand keep**ferro_options(), which keeps Alembic off Ferro's tables and tracking tables. See Alembic.
An app that migrates at start-up¶
A desktop or local-first app cannot baseline its users' files by hand. The release that switches to migrations does it in its start-up code: a file with no migration records is baselined at 0001 (the schema the previous release's auto-migrate built, if 0001 was generated from those models), and then up() applies the rest:
import ferro
import ferro.migrations
async def start(database_url: str) -> None:
await ferro.connect(database_url)
try:
await ferro.migrations.baseline(target="0001") # the previous release's schema
except ferro.migrations.AlreadyTrackedError:
pass # already tracked: an earlier start, or another instance, got here first
await ferro.migrations.up()
A file the previous release built matches 0001 and is recorded. A brand-new, empty file matches nothing, so the baseline records nothing and up() creates the tables from 0001.
baseline() itself decides whether the file still needs adopting. On a tracked file (an earlier start recorded it, or up() has run) it records nothing and raises AlreadyTrackedError, a MigrationRefused whose applied names the recorded migrations, head the newest of them and report the file's status. Nothing is wrong, so the app carries on to up().
This also covers a race. Two instances that start together (two windows of the app, or two services' pre-deploys against one shared database) both find no records and both call baseline(). The run lock lets one of them record, and the other raises AlreadyTrackedError. Do not check status() first to decide whether to call baseline(): the answer can change before the baseline takes the lock. Every other refusal (a target the directory lacks, a lock wait that runs out) is still raised as MigrationRefused.
The baseline still checks the file. A file that matches neither (an even older build, or one edited by hand) is not recorded: on drift, baseline() raises nothing and returns a BaselineReport whose drift lists the differences. up() then refuses to run 0001 over the file's tables, so the app fails loudly at start-up instead of serving a half-migrated file. To fail at the baseline instead, keep the report and call report.raise_for_problems().
Undoing a baseline¶
ferro migrate baseline --remove prints:
It deletes the records the baseline wrote, and nothing else: the migrations are pending again. It is refused while a migration a run applied stands above the baseline (revert that one with ferro migrate down first); in code, remove_baseline() raises AppliedAboveBaselineError, whose above names each such migration. down itself never reverts a baselined migration, whose down would drop tables it never created:
default · sqlite:///./app.db
ferro migrate: 0002 was recorded by `baseline` and created nothing here; `down` can go no lower than 0002. Nothing was reverted.
In code, ferro.migrations.baseline() and ferro.migrations.remove_baseline() do the same as the two commands.
See also¶
- Schema management overview: one door per database
- Deploying migrations: what comes after the baseline
- CLI reference and Migrations API reference: every flag and parameter of
baseline