Migrations

Tooling and APIs to safely change the schema of your database.

The strict schema of tables and columns is what enables type-safe queries to the database. But since the schema is stored in the database too, changing it needs to happen through migrations developed as part of your app. Drift provides APIs to make most migrations easy to write, as well as command-line and testing tools to ensure the migrations are correct.

Manual setup

Drift provides a migration API that can be used to gradually apply schema changes after bumping the schemaVersion getter inside the Database class. To use it, override the migration getter.

Here's an example: Let's say you wanted to add a due date to your todo entries (v2 of the schema). Later, you decide to also add a priority column (v3 of the schema).

class Todos extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get title => text().withLength(min: 6, max: 10)();
  TextColumn get content => text().named('body')();
  IntColumn get category => integer().nullable()();
  DateTimeColumn get dueDate =>
      dateTime().nullable()(); // new, added column in v2
  IntColumn get priority => integer().nullable()(); // new, added column in v3
}

We can now change the database class like this:

@override
int get schemaVersion => 3; // bump because the tables have changed.

@override
MigrationStrategy get migration {
  return MigrationStrategy(
    onCreate: (Migrator m) async {
      await m.createAll();
    },
    onUpgrade: (Migrator m, int from, int to) async {
      if (from < 2) {
        // we added the dueDate property in the change from version 1 to
        // version 2
        await m.addColumn(todos, todos.dueDate);
      }
      if (from < 3) {
        // we added the priority property in the change from version 1 or 2
        // to version 3
        await m.addColumn(todos, todos.priority);
      }
    },
  );
}
// The rest of the class can stay the same

You can also add individual tables or drop them - see the reference of Migrator for all the available options.

You can also use higher-level query APIs like select, update or delete inside a migration callback. However, be aware that drift expects the latest schema when creating SQL statements or mapping results. For instance, when adding a new column to your database, you shouldn't run a select on that table before you've actually added the column. In general, try to avoid running queries in migration callbacks if possible.

Writing migrations without any tooling support isn't easy. Since correct migrations are essential for app updates to work smoothly, we strongly recommend using the tools and testing framework provided by drift to ensure your migrations are correct. To do that, export old versions to then use easy step-by-step migrations or tests.

General tips

To ensure your schema stays consistent during a migration, you can wrap it in a transaction block. However, be aware that some pragmas (including foreign_keys) can't be changed inside transactions. Still, it can be useful to:

  • always re-enable foreign keys before using the database, by enabling them in beforeOpen.
  • disable foreign-keys before migrations
  • run migrations inside a transaction
  • make sure your migrations didn't introduce any inconsistencies with PRAGMA foreign_key_check.

With all of this combined, a migration callback can look like this:

return MigrationStrategy(
  onUpgrade: (m, from, to) async {
    // disable foreign_keys before migrations
    await customStatement('PRAGMA foreign_keys = OFF');

    await transaction(() async {
      // put your migration logic here
    });

    // Assert that the schema is valid after migrations
    if (kDebugMode) {
      final wrongForeignKeys =
          await customSelect('PRAGMA foreign_key_check').get();
      assert(wrongForeignKeys.isEmpty,
          '${wrongForeignKeys.map((e) => e.data)}');
    }
  },
  beforeOpen: (details) async {
    await customStatement('PRAGMA foreign_keys = ON');
    // ....
  },
);

Post-migration callbacks

The beforeOpen parameter in MigrationStrategy can be used to populate data after the database has been created. It runs after migrations, but before any other query. Note that it will be called whenever the database is opened, regardless of whether a migration actually ran or not. You can use details.hadUpgrade or details.wasCreated to check whether migrations were necessary:

beforeOpen: (details) async {
    if (details.wasCreated) {
      final workId = await into(categories).insert(Category(description: 'Work'));

      await into(todos).insert(TodoEntry(
            content: 'A first todo entry',
            category: null,
            targetDate: DateTime.now(),
      ));

      await into(todos).insert(
            TodoEntry(
              content: 'Rework persistence code',
              category: workId,
              targetDate: DateTime.now().add(const Duration(days: 4)),
      ));
    }
},

You could also activate pragma statements that you need:

beforeOpen: (details) async {
  if (details.wasCreated) {
    // ...
  }
  await customStatement('PRAGMA foreign_keys = ON');
}

During development

During development, you might be changing your schema very often and don't want to write migrations for that yet. You can just delete your apps' data and reinstall the app - the database will be deleted and all tables will be created again. Please note that uninstalling is not enough sometimes - Android might have backed up the database file and will re-create it when installing the app again.

You can also delete and re-create all tables every time your app is opened, see this comment on how that can be achieved.

Verifying a database schema at runtime

Instead (or in addition to) writing tests to ensure your migrations work as they should, you can use a new API from drift_dev 1.5.0 to verify the current schema without any additional setup.

// import the migrations tooling
import 'package:drift_dev/api/migrations.dart';

class MyDatabase extends _$MyDatabase {
  @override
  MigrationStrategy get migration => MigrationStrategy(
        onCreate: (m) async {/* ... */},
        onUpgrade: (m, from, to) async {/* your existing migration logic */},
        beforeOpen: (details) async {
          // your existing beforeOpen callback, enable foreign keys, etc.

          if (kDebugMode) {
            // This check pulls in a fair amount of code that's not needed
            // anywhere else, so we recommend only doing it in debug builds.
            await validateDatabaseSchema();
          }
        },
      );
}

When you use validateDatabaseSchema, drift will transparently:

  • collect information about your database by reading from sqlite3_schema.
  • create a fresh in-memory instance of your database and create a reference schema with Migrator.createAll().
  • compare the two. Ideally, your actual schema at runtime should be identical to the fresh one even though it grew through different versions of your app.

When a mismatch is found, an exception with a message explaining exactly where another value was expected will be thrown. This allows you to find issues with your schema migrations quickly.


Exporting schemas

Store all schema versions of your app for validation.

Schema migration helpers

Use generated code reflecting over all schema versions to write migrations step-by-step.

Testing migrations

Generate test code to write unit tests for your migrations.

The migrator API

How to run ALTER statements and complex table migrations.