A database migration tool does two jobs: it applies schema changes in a fixed order, and it writes down
in the database which changes have already run. This page puts three .NET tools through the same two
changes on PostgreSQL 18.4 (create a patients table, then add an email column
with a unique index): EF Core migrations, DbUp and FluentMigrator. For each one it shows what the tool
printed, what it left in the database, how the second change was undone, and what a change that fails
halfway leaves behind.
The short version: with EF Core you change C# classes and the tool writes the migration. With DbUp you
write every SQL file yourself and a small console program runs the new ones. With FluentMigrator you
write C# classes with an Up and a Down method and the runner turns them into
SQL. All three keep one table in the database that lists what has been applied.
- Start PostgreSQL 18 in a container published on a loopback port and create one empty database per tool
- EF Core: a console project with the Npgsql provider and the Design package, an entity and a context, then
dotnet ef migrations add InitialCreateanddotnet ef database update - Add the
Emailproperty, add a second migration, update, then go back withdotnet ef database update InitialCreate - DbUp: the
dbup-postgresqlpackage, two numbered.sqlfiles embedded in the project, run the program twice and read theschemaversionstable - FluentMigrator: the three FluentMigrator packages plus Npgsql, two migration classes,
MigrateUp(), thenMigrateDownto the first version - Break a third change on purpose in DbUp and in FluentMigrator and look at what is left in the database
The test
One postgres:18 container (server 18.4) held one database per tool. The three console
projects target net10.0; they were built with SDK 11.0.100, the newest on this PC, and ran
on runtime 10.0.15. Versions as restored: Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3 with EF Core
10.0.4 and the dotnet-ef tool 10.0.0; dbup-postgresql 7.0.1 (dbup-core 6.1.1);
FluentMigrator, FluentMigrator.Runner and FluentMigrator.Runner.Postgres 8.0.1 with Npgsql 10.0.3.
Table and column names are lower case in all three runs.
EF Core migrations: the model is the source
Here the C# model is what you edit. The entity has Id, FullName and
CreatedAt; the context maps them to lower-case table and column names one by one, so that
all three tools end up with the same schema. This is the mapping after the second change:
e.ToTable("patients");
e.Property(p => p.Id).HasColumnName("id");
e.Property(p => p.FullName).HasColumnName("full_name").IsRequired();
e.Property(p => p.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
e.Property(p => p.Email).HasColumnName("email");
e.HasIndex(p => p.Email).IsUnique();
One snag came before the first migration. With SDK 11 installed, adding the Design package without a
version picked 11.0.0 for a net10.0 project and the restore failed:
error: NU1202: Package Microsoft.EntityFrameworkCore.Design 11.0.0 is not compatible with net10.0 (.NETCoreApp,Version=v10.0). Package Microsoft.EntityFrameworkCore.Design 11.0.0 supports: net11.0 (.NETCoreApp,Version=v11.0)
dotnet add package Microsoft.EntityFrameworkCore.Design --version 10.0.4, the EF Core
version the Npgsql provider had brought in, fixed it. Then the first change:
PS> dotnet ef migrations add InitialCreate
...
Done. To undo this action, use 'ef migrations remove'
PS> dotnet ef database update
...
Failed executing DbCommand (31ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT "MigrationId", "ProductVersion"
FROM "__EFMigrationsHistory"
ORDER BY "MigrationId";
Acquiring an exclusive lock for migration application. See https://aka.ms/efcore-docs-migrations-lock for more information if this takes too long.
Applying migration '20261113052000_InitialCreate'.
Done.
The "Failed executing DbCommand" lines look like an error and are not one here: the tool asked for its
history table before that table existed, then created it and applied the migration, and the exit code
was 0. Every dotnet ef command also warned that tool version 10.0.0 is older than runtime
10.0.4 and went on with the work.
For the second change the only edit was the Email property and its index in the model.
dotnet ef migrations add AddPatientEmail then wrote this Up method, with a
matching Down that drops the index and the column:
migrationBuilder.AddColumn<string>(
name: "email",
table: "patients",
type: "text",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_patients_email",
table: "patients",
column: "email",
unique: true);
After the second database update the history table held one row per migration, with the EF
Core version that applied it:
MigrationId | ProductVersion
--------------------------------+----------------
20261113052000_InitialCreate | 10.0.4
20261113052055_AddPatientEmail | 10.0.4
Running dotnet ef database update again printed "No migrations were applied. The database
is already up to date." Going back one step is the same command with the name of the migration that
should be the last one applied:
PS> dotnet ef database update InitialCreate
...
Reverting migration '20261113052055_AddPatientEmail'.
Done.
PS> dotnet ef migrations list
...
20261113052000_InitialCreate
20261113052055_AddPatientEmail (Pending)
The column and the index were gone and the history table was back to one row. For a server where the
tool itself may not run, dotnet ef migrations script --idempotent printed plain SQL in which
every statement checks the history table first, one transaction per migration:
START TRANSACTION;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20261113052055_AddPatientEmail') THEN
ALTER TABLE patients ADD email text;
END IF;
END $EF$;
...
COMMIT;
DbUp: numbered SQL files and a journal
With DbUp the SQL is yours. Two files sit in a Scripts folder and are embedded in the
assembly with one EmbeddedResource line in the project file:
CREATE TABLE patients (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
full_name text NOT NULL,
created_at timestamp with time zone NOT NULL DEFAULT now()
);
ALTER TABLE patients ADD COLUMN email text;
CREATE UNIQUE INDEX ix_patients_email ON patients (email);
The program that runs them is a few lines:
var builder = DeployChanges.To
.PostgresqlDatabase(connectionString)
.WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
.LogToConsole();
...
var upgrader = builder.Build();
var result = upgrader.PerformUpgrade();
The first run, with only the first file present:
PS> dotnet run --no-build
2026-11-13 10:52:09 +05:30 [INF] Beginning database upgrade
2026-11-13 10:52:09 +05:30 [INF] Checking whether journal table exists
2026-11-13 10:52:09 +05:30 [INF] Journal table does not exist
2026-11-13 10:52:09 +05:30 [INF] Executing Database Server script 'ClinicDbUp.Scripts.0001_create_patients.sql'
2026-11-13 10:52:09 +05:30 [INF] Checking whether journal table exists
2026-11-13 10:52:09 +05:30 [INF] Creating the "schemaversions" table
2026-11-13 10:52:09 +05:30 [INF] The "schemaversions" table has been created
2026-11-13 10:52:09 +05:30 [INF] Upgrade successful
With the second file added and the project rebuilt, the next run fetched the list of executed scripts
and ran only 0002_add_patient_email.sql. The journal table, schemaversions,
then held the two script names:
schemaversionsid | scriptname | applied
------------------+-----------------------------------------------+----------------------------
1 | ClinicDbUp.Scripts.0001_create_patients.sql | 2026-11-13 10:52:09.169149
2 | ClinicDbUp.Scripts.0002_add_patient_email.sql | 2026-11-13 10:52:21.25255
Three things about that table are worth knowing before you rely on it. A script is recorded under its
embedded resource name: project, folder, then file name. The
applied column is a timestamp without time zone and held this PC's local time, 10:52 IST,
while the server's clock was on UTC. And the journal does not notice edits: after a comment line was
added to the already applied first script, the next run printed "No new scripts need to be executed -
completing." and changed nothing.
The program has no step for going back. The second change was undone the way it would be on a real project, with a new script that goes forward:
DROP INDEX ix_patients_email;
ALTER TABLE patients DROP COLUMN email;
Saved as 0004_drop_patient_email.sql, it ran like any other script and became one more row
in the journal; the row for script 0002 stayed.
FluentMigrator: C# classes with Up and Down
FluentMigrator has no model to compare. Each change is a class with a version number that you choose and both directions written by hand:
[Migration(202611130002)]
public class AddPatientEmail : Migration
{
public override void Up()
{
Alter.Table("patients")
.AddColumn("email").AsString().Nullable();
Create.Index("ix_patients_email").OnTable("patients")
.OnColumn("email").Ascending()
.WithOptions().Unique();
}
public override void Down()
{
Delete.Index("ix_patients_email").OnTable("patients");
Delete.Column("email").FromTable("patients");
}
}
The runner is set up once in Program.cs and finds the classes by scanning the assembly:
var services = new ServiceCollection()
.AddFluentMigratorCore()
.ConfigureRunner(rb => rb
.AddPostgres()
.WithGlobalConnectionString(connectionString)
.ScanIn(typeof(Program).Assembly).For.Migrations())
.AddLogging(lb => lb.AddFluentMigratorConsole())
...
runner.MigrateUp();
Two things had to be added before it ran. The Postgres runner package does not bring the database
driver: the first run stopped with "Unable to load the driver. Attempted to load: Npgsql" until the
Npgsql package was referenced as well. And the console logger prints only statement names until
ShowSql is switched on in FluentMigratorLoggerOptions. With that on, the
second change looked like this:
PS> dotnet run --no-build
...
202611130002: AddPatientEmail migrating
...
ALTER TABLE "public"."patients" ADD "email" text;
...
CREATE UNIQUE INDEX "ix_patients_email" ON "public"."patients" ("email" ASC);
...
INSERT INTO "public"."VersionInfo" ("Version","AppliedOn","Description") VALUES (202611130002,(now() at time zone 'UTC'),'AddPatientEmail');
Committing Transaction
202611130002: AddPatientEmail migrated
Its history table is VersionInfo, written in mixed case, so it needs double quotes in SQL.
It stores the version number, the time in UTC and the class name:
Version | AppliedOn | Description
--------------+----------------------------+-----------------
202611130001 | 2026-11-13 05:25:11.133408 | CreatePatients
202611130002 | 2026-11-13 05:25:39.279529 | AddPatientEmail
Going back is a call to runner.MigrateDown(202611130001), which ran the Down
method of everything above that version:
PS> dotnet run --no-build -- down 202611130001
...
202611130002: AddPatientEmail reverting
...
DROP INDEX "public"."ix_patients_email";
...
ALTER TABLE "public"."patients" DROP COLUMN "email";
...
DELETE FROM "public"."VersionInfo" WHERE "Version" = 202611130002;
Committing Transaction
202611130002: AddPatientEmail reverted
Read the generated SQL at least once. Identity() on the id column came out as
GENERATED ALWAYS AS IDENTITY, where the EF Core migration and the hand-written DbUp script
both said GENERATED BY DEFAULT. The runner package also restores the runners for every
database it supports, and each build printed NU1902 and NU1903 warnings for
System.Security.Cryptography.Pkcs 9.0.0, a package somewhere in that set. The same migrations ran from
the command line too: FluentMigrator.DotNet.Cli 8.0.1, installed as a local tool, did
dotnet fm migrate, rollback and list migrations against the built
assembly with -p PostgreSQL; -p Postgres was refused as an unknown processor.
When a change fails halfway
The third change was broken on purpose in the two tools where the SQL is yours: one valid statement
that creates a visits table, followed by an ALTER TABLE with a syntax error.
DbUp, built as shown above, stopped with exit code -1:
2026-11-13 10:52:42 +05:30 [INF] Executing Database Server script 'ClinicDbUp.Scripts.0003_broken.sql'
2026-11-13 10:52:42 +05:30 [INF] Checking whether journal table exists
2026-11-13 10:52:42 +05:30 [INF] Npgsql exception has occurred in script: 'ClinicDbUp.Scripts.0003_broken.sql'
2026-11-13 10:52:42 +05:30 [ERR] Script block number: 1; Block line 1228; Position: 60; Message: 42601: syntax error at end of input
Afterwards the visits table existed and script 0003 was not in the journal: the first
statement had been kept and the script counted as not run. Running a corrected script against that
database was not tried here; the table it would create is already there. The same test on a fresh database, with one more line on the builder,
builder.WithTransactionPerScript(), failed with the same message and left no
visits table behind.
FluentMigrator wrapped each migration in a transaction without being asked. The run applied migration 2, started migration 3, created the table, hit the bad statement and printed:
!!! An error occurred executing the following sql:
ALTER TABLE patients ADD COLUMN phone text NOT NULL DEFAULT;
The error was 42601: syntax error at end of input
After it, visits did not exist, version 202611130003 was not in VersionInfo,
and version 202611130002 from the same run was. The program ended with an unhandled exception because
the sample has no try and catch around MigrateUp(). No failing change was run for EF Core.
Side by side
| EF Core migrations | DbUp | FluentMigrator | |
|---|---|---|---|
| What you write | C# model; the tool generates the migration | Every SQL file | C# classes with Up and Down |
| History table | __EFMigrationsHistory: migration id, EF Core version | schemaversions: script name, time applied (the PC's local time) | VersionInfo: version, time applied (UTC), description |
| A second run printed | No migrations were applied. The database is already up to date. | No new scripts need to be executed - completing. | Only its opening transaction lines |
| Undoing change 2 | database update InitialCreate ran the generated Down | A new forward script, 0004 | MigrateDown ran the hand-written Down |
| The failing change | Not run | Half applied by default; nothing left with WithTransactionPerScript() | Rolled back; the earlier migration of the same run stayed |
| SQL to review before production | migrations script --idempotent | The files themselves | The log with ShowSql on |
Which one fits
My reading of these runs, not a ranking. If the application already uses EF Core for its data access, its migrations ask the least of you: each change here was two commands and no SQL, and the idempotent script gives whoever runs production something to read first. If the team writes its SQL by hand, or the database is shared with code that is not .NET, DbUp runs the files as they are and adds nothing of its own; switch on a transaction per script after seeing what the default left behind, and plan every undo as a new script. FluentMigrator sits between the two: C# without an EF model, a way back that worked from code and from the command line, and generated SQL that you should read once per kind of column.
Not run for this page, so not described: Flyway, Liquibase, Evolve, grate and SQL Server database projects. The documentation for the three that were run is at learn.microsoft.com (EF Core migrations), dbup.readthedocs.io and fluentmigrator.github.io.
Frequently asked
- What are the options for database migrations in .NET?
- Three were run for this page on PostgreSQL 18.4. EF Core migrations generate a C# migration from changes to the model and apply it with dotnet ef database update. DbUp runs numbered SQL files that you write and records each one in a journal table. FluentMigrator runs C# classes with an Up and a Down method. Each keeps a table in the database that lists what has been applied.
- Where do EF Core, DbUp and FluentMigrator record which migrations have run?
- In a table inside the database being migrated. In this test EF Core used __EFMigrationsHistory with the migration id and the EF Core version, DbUp used schemaversions with the script name and the time applied, and FluentMigrator used VersionInfo with the version number, the time applied in UTC and the class name.
- How do I roll back a database migration in .NET?
- With EF Core, run dotnet ef database update followed by the name of the migration that should be the last one applied; in the test it printed Reverting migration and removed the row from the history table. With FluentMigrator, MigrateDown with the target version ran the Down method. The DbUp program in the test has no step back, so the change was undone with a new forward script that dropped the index and the column.
Every dotnet ef command with its output is in
the dotnet ef commands, run and explained,
and how to change a table that already holds data, with the add-column, backfill and drop sequence run on
two engines, is
Part 11 of Database Design for Developers.