skip to content

Your Flutter app's Drift schema gains a new column in the next release; how do you migrate existing users' databases safely and prove it works?

level: seniorimportance: must knowfreq 50%

answer

  1. user_version versus schemaVersion
  2. default onUpgrade throws
  3. make-migrations and drift_schemas/
  4. stepByStep fromXToY with a schema snapshot
  5. SchemaVerifier migrateAndValidate in tests

basics

~10 s

Bump schemaVersion, handle the step in MigrationStrategy.onUpgrade (ideally the generated stepByStep with m.addColumn), and let make-migrations export schema snapshots and generate tests that migrate a v1 database with SchemaVerifier and validate the result.

solid answer

~40 s

Drift compares the SQLite `user_version` stored in the file with your `schemaVersion`. On a fresh install it calls `onCreate` (default `m.createAll()`); on a version change it calls `onUpgrade(m, from, to)`, whose default throws an exception telling you to write a migration. So I bump `schemaVersion` to 2 and run `dart run drift_dev make-migrations`, which exports a schema snapshot into `drift_schemas/`, writes a `database.steps.dart` with a `stepByStep` helper, and generates migration tests. In `from1To2: (m, schema)` I call `m.addColumn(schema.checkIns, schema.checkIns.note)`, where `schema` is the v2 snapshot, so later schema changes cannot break this step. The generated tests use `SchemaVerifier.startAt(1)` and `migrateAndValidate(db, 2)`, plus a data-integrity test, and I keep `beforeOpen` for `PRAGMA foreign_keys = ON`.

code

yaml · 9 lines
yaml
targets:
  $default:
    builders:
      drift_dev:
        options:
          databases:
            habits_db: lib/data/database.dart
          schema_dir: drift_schemas/
          test_dir: test/drift/

go deeper

for a junior

Recall that changing a table means bumping schemaVersion and writing an onUpgrade step, or upgraded users crash.

for a middle

Explain user_version versus schemaVersion, the onCreate, onUpgrade and beforeOpen order, and the Migrator calls for common changes.

for a senior

Walk through make-migrations, schema snapshots, stepByStep and SchemaVerifier tests, and cover skipped versions, downgrades and data-integrity checks.

for a principal

Frame schema evolution as a release policy: immutable snapshots, a supported-version window, and when a destructive rebuild beats a long migration chain.

## How Drift decides that a migration is needed A **migration** transforms the database file already on a user's phone into the shape the new app version expects. **Drift** tracks this with SQLite's `user_version` pragma, a number stored in the database file, and your database class's `schemaVersion` getter. When the database opens, drift builds `OpeningDetails(versionBefore, versionNow)`: - `wasCreated` is true when there was no stored version, so drift calls `MigrationStrategy.onCreate`, whose default is `m.createAll()`. - `hadUpgrade` is true when the stored version differs from `schemaVersion`, so drift calls `onUpgrade(m, from, to)`. The same callback receives downgrades, where `from > to`. - `beforeOpen` runs on every open after either callback and before any other query, which makes it the place for `PRAGMA foreign_keys = ON` or seeding data when `details.wasCreated`. Drift writes the new version into the file only after these callbacks complete, so a hand-written migration that throws leaves the old number in place (the generated step runner described below also records each completed step). The trap is the **default `onUpgrade`**: it throws an exception saying you bumped the schema version without providing a strategy. Forgetting to bump `schemaVersion` fails differently: the migration never runs, and queries touching the new column fail with SQL errors on upgraded installs only. ## The guided workflow: make-migrations Drift's recommended path is tooling, not hand-written `if (from < 2)` ladders. 1. Configure the database in `build.yaml` under `drift_dev` options: `databases: {my_database: lib/database.dart}`. Optional keys set `schema_dir` (default `drift_schemas/`) and `test_dir` (default `test/drift/`). 2. Before changing anything, run `dart run drift_dev make-migrations` once to export the v1 schema. 3. Change the tables, bump `schemaVersion` to 2 and run the command again. 4. It writes a `database.steps.dart` file next to the database class, containing a `stepByStep` helper, and generates migration tests. | Artifact | Purpose | |---|---| | `drift_schemas/` JSON snapshots | the exact schema of every released version | | `database.steps.dart` | `stepByStep(from1To2: ...)` with a typed snapshot per version | | generated tests | verify each step produces the expected schema | ## Writing the step Inside `from1To2: (m, schema) async { ... }`, `schema` is a generated snapshot of version 2, and the `Migrator` `m` targets that version. You call `m.addColumn(schema.checkIns, schema.checkIns.note)`. Referencing the snapshot rather than the live table getter matters: if version 3 later drops or renames that column, the version-2 step still compiles and still produces the version-2 shape. Other `Migrator` operations include `createTable`, `deleteTable`, `renameColumn`, `dropColumn` and `alterTable(TableMigration(...))`, which re-creates a table to change a type or constraint. The generated `stepByStep` runner records `user_version` after each successful step on SQLite, so a crash halfway leaves the file at a consistent intermediate version. It also refuses downgrades with a `StateError`, which is what a user who sideloads an older build will hit. ## Why not a hand-written version ladder Drift still supports the classic manual style, `if (from < 2) await m.addColumn(checkIns, checkIns.note);`, and drift's own docs show it. Its weakness appears later: when version 3 deletes the column, the version-2 branch references a getter that no longer exists, and someone rewrites history by hand. Manual ladders also make it easy to test only the newest hop. Drift's docs flag manual migrations as error-prone and point to `make-migrations`, whose snapshots and generated tests keep every historical step compiling and checkable. Projects that already have a manual ladder can pin a floor with `Migrator.runMigrationSteps` and switch to steps from that version onwards. ## Proving it before release - The generated tests use `SchemaVerifier`: `startAt(1)` opens a database with the v1 schema, you construct your real database class on that connection, and `migrateAndValidate(db, 2)` runs your migration and compares every `CREATE` statement against the exported v2 schema, throwing `SchemaMismatch` on a difference. - A **data-integrity test** inserts rows through the old schema, migrates, and asserts they survived. Generated snapshot data classes (`--data-classes --companions`) make this typed. - Avoid high-level `select` or `update` calls inside migrations; they are compiled against the latest schema and can reference columns that do not exist yet. - A new non-nullable column needs a default or it cannot be added to existing rows. ## Senior-level judgement A migration runs on devices you cannot inspect, on every version users skip from, so treat each released snapshot as immutable, test every `from` version you still support, and wrap destructive steps with foreign-key checks as drift's docs show. During development only, reinstalling is fine, but Android auto-backup can restore an old database file on reinstall.

  • In Drift, why does a step written as from1To2: (m, schema) use schema.checkIns instead of the database's checkIns getter?
    The getter always reflects the latest schema. If version 3 removes or renames the column, a step built on it would no longer compile or would produce the wrong shape. The `schema` parameter is a frozen version-2 snapshot generated from the exported JSON, so the step stays correct forever.
  • What happens with Drift's generated stepByStep when a user installs an older app build over a newer database?
    `onUpgrade` receives `from > to`, and the step runner throws a `StateError` because steps only upgrade. You either accept that, or handle downgrades yourself before calling the steps, for example by exporting data and recreating tables.
  • Where do you enable foreign keys in a Drift database, and why not in onCreate?
    In `beforeOpen`, with `customStatement('PRAGMA foreign_keys = ON')`. The pragma is per connection and not stored in the file, so it must run on every open; `onCreate` runs only once, on the very first launch.

saying these in an interview costs you the question

  • Drift detects schema changes and alters tables automatically on open.
  • Bumping schemaVersion alone is enough; the default onUpgrade migrates.
  • Migration steps should use the live table getters of the current schema.
  • A passing app launch on a fresh emulator proves the migration works.
  • Old schema snapshots can be regenerated later, so there is no need to commit them.