Migration
A migration changes your database schema step by step: create a table, add a column, and so on.
Each migration has an up (do the change) and a down (undo it).
Write migrations in Dart with the schema builder.
The same migration then works on Postgres, MySQL, MariaDB and SQLite.
Plain .sql migrations still work too.
Create a migration
This makes db/migration/2026_10_21_120000_create_users_table.dart:
import 'package:dox_migration/dox_migration.dart';
class CreateUsersTable extends Migration {
@override
Future<void> up(Schema schema) async {
await schema.create('users', (Table table) {
table.id();
table.string('email').unique();
table.string('password');
table.timestamps();
});
}
@override
Future<void> down(Schema schema) async {
await schema.dropIfExists('users');
}
}
The name gives the start of the file:
| Name | File starts with |
|---|---|
create_users_table |
schema.create('users', ...) and dropIfExists('users') |
add_avatar_to_users_table |
schema.table('users', ...) in up and down |
| any other name | empty up and down |
create:migration is the old name of the same command.
Raw SQL and data in a Dart migration
schema.driver runs raw SQL on the same connection and transaction:
await schema.driver.execute("UPDATE users SET role = 'admin' WHERE id = 1");
Do not use models or QueryBuilder in a migration. They use another connection.
The registry
Dart can not load a file at run time in an AOT build.
So Dox keeps a list of your Dart migrations in db/migration/migrations.dart:
// Made by `dox make:migration` and the `dox migration:*` commands.
import 'package:dox_migration/dox_migration.dart';
import '2026_10_21_120000_create_users_table.dart' as m1;
final List<MigrationEntry> migrations = <MigrationEntry>[
MigrationEntry(
'2026_10_21_120000_create_users_table',
m1.CreateUsersTable(),
),
];
dox make:migrationand everydox migration:*command write this file again. You do not edit it.- It finds each file named
YYYY_MM_DD_HHMMSS_name.dartand itsclass ... extends Migration. - The name of a migration is its file name without
.dart. The order is by name.
The first dox make:migration also makes bin/migrate.dart in your app:
import 'dart:io';
import 'package:dox_migration/dox_migration.dart';
import '../db/migration/migrations.dart';
Future<void> main(List<String> args) async {
exitCode = await Migrator(migrations: migrations).run(args);
}
The dox migration:* commands run dart run bin/migrate.dart <command>.
So your migrations run as part of your app code.
Run migrations
| Command | What it does |
|---|---|
dox migration:run |
Run the migrations that did not run yet. Short name: dox migrate. |
dox migration:rollback |
Undo the last batch. |
dox migration:rollback --step=2 |
Undo the last 2 migrations, even across batches. |
dox migration:reset |
Undo all migrations. |
dox migration:refresh |
Undo all migrations, then run them again. |
dox migration:status |
Show each migration, its batch, and if it ran. |
migrate:rollback, migrate:reset, migrate:refresh and migrate:status are short names.
A batch is the group of migrations that ran in one migration:run.
Migrations are undone newest first.
The ran migrations are saved in the dox_db_migration table.
When a migration fails, the error shows its name, and the command exits with code 1.
The failed migration is not saved, and the later ones do not run.
You can also run dart run bin/migrate.dart run (or rollback --step=2, reset, refresh, status) yourself.
Run on server start
The AOT server binary can run the migrations, because the registry is compiled in:
import 'package:dox_migration/dox_migration.dart';
import '../db/migration/migrations.dart';
void main() async {
Dox().initialize(appConfig);
await Migrator(migrations: migrations).migrate();
await Dox().startServer();
}
Keep the db/migration folder next to the binary if you also have .sql migrations.
Database settings
The migrations use the .env file in your project.
When there is no .env file (for example in Docker), they use the environment variables.
DB_CONNECTION=postgres # or mysql (also MariaDB), or sqlite
DB_HOST=localhost
DB_PORT=5432
DB_NAME=postgres
DB_USERNAME=admin
DB_PASSWORD=password
For SQLite, only two variables are needed:
Info
The default port is 5432 for postgres and 3306 for mysql.
One run uses one database connection.
Transactions
Each migration runs in one transaction. On Postgres and SQLite, a failed migration undoes all its changes.
MySQL
On MySQL and MariaDB, CREATE, ALTER and DROP save right away.
So if a migration fails half way, these changes stay. Only data changes are undone.
Keep each MySQL migration small: one table change per migration.
To run a migration without a transaction (for example CREATE INDEX CONCURRENTLY on Postgres):
SQLite foreign keys
On SQLite, foreign key checks are off while a migration runs. This lets Dox make a table again (see SQLite notes). Before the end, Dox checks all foreign keys. The migration fails if one is broken.
SQL migrations
A .sql migration is still fine. Use it for SQL that only one database understands.
-- up
CREATE TABLE IF NOT EXISTS blog (
id serial PRIMARY KEY,
title VARCHAR(255) NOT NULL
);
-- down
DROP TABLE IF EXISTS blog
- Put the
upSQL under a line-- up, and thedownSQL under a line-- down. Each of these lines must be on its own line. Other--lines are normal SQL comments. - Statements are split on
;. A;inside quotes, comments or a postgres$$ ... $$body does not split.
SQLite ids
In SQLite, id serial PRIMARY KEY does not make an auto increment id. The id stays NULL.
Use id INTEGER PRIMARY KEY AUTOINCREMENT for SQLite. table.id() in Dart does this for you.
Dart and SQL in one folder
Both kinds can live in db/migration. They run in file name order (the timestamp).
- With
bin/migrate.dart: the Migrator runs the Dart migrations from the registry and reads the.sqlfiles from the folder, merged by name. - Without
bin/migrate.dart(a project with only.sqlfiles):doxruns the.sqlfiles itself, like before. - A Dart and a
.sqlmigration with the same name is an error.