DbUp and FluentMigrator solve the same problem from opposite ends. DbUp is a small library that runs the SQL scripts you wrote, in order, exactly once, and remembers which ones ran. FluentMigrator is a framework in which every change is a C# class with an Up() and a Down(), translated to the dialect of the database at run time. Pick DbUp when your team thinks in SQL and wants full control of every statement; pick FluentMigrator when you need one migration history across several database engines or want to roll changes back in code.
What each tool actually does#
Both tools share the mental model that a database is the result of an ordered series of transitions, not a snapshot you diff against. Neither inspects your entity classes. Neither guesses. You write the change, the tool applies it and records that it did.
DbUp keeps that idea as small as it gets. You build a console application (or a startup step in your service), point it at a set of SQL scripts and a connection string, and call PerformUpgrade(). Scripts that have not run yet are executed in name order, and each completed script is written to a journal table, SchemaVersions by default. That is the whole product.
FluentMigrator turns each migration into a class decorated with a version number. Inside Up() you describe the change with a fluent API: create a table, add a column, make it nullable, add a foreign key. FluentMigrator translates that description to the SQL of whichever provider you run against and records the version in its own table, VersionInfo. Because Down() exists, it can also walk the history backwards.
DbUp in practice#
A DbUp project is a folder of SQL files marked as embedded resources and a few lines of C#:
using System.Reflection;
using DbUp;
var connectionString = args[0];
var upgrader = DeployChanges.To
.SqlDatabase(connectionString)
.WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
.WithTransactionPerScript()
.LogToConsole()
.Build();
var result = upgrader.PerformUpgrade();
return result.Successful ? 0 : 1;
The scripts are ordinary DDL. Because DbUp orders them by name, a numeric prefix is the usual convention:
-- 0001_create_owners_and_pets.sql
CREATE TABLE owners
(
id INT NOT NULL,
first_name VARCHAR(50) NOT NULL,
last_name VARCHAR(50) NOT NULL,
address VARCHAR(255),
city VARCHAR(80),
telephone VARCHAR(20),
CONSTRAINT pk_owners PRIMARY KEY (id)
);
CREATE TABLE pets
(
id INT NOT NULL,
name VARCHAR(50) NOT NULL,
owner_id INT NOT NULL,
CONSTRAINT pk_pets PRIMARY KEY (id),
CONSTRAINT fk_pets_owners FOREIGN KEY (owner_id) REFERENCES owners (id)
);
Three details matter more than they look:
- Transactions are opt-in. The default runs scripts without a transaction.
WithTransactionPerScript()wraps each script;WithTransaction()wraps the whole run. On SQL Server and PostgreSQL that gives you clean rollback of a failed script. On MySQL, DDL commits implicitly, so a failed script can still leave half its work behind whatever DbUp asks for. - The journal is the truth. DbUp compares script names against
SchemaVersions. Rename a script after it ran and DbUp will run it again. Edit a script after it ran and nothing happens, because the name is unchanged. Treat applied scripts as immutable. - Run-once is the default, run-always is available. Scripts for views, functions or stored procedures can be marked to run on every deployment, which keeps their definition in one file instead of a chain of alterations.
Providers exist for SQL Server, PostgreSQL, MySQL, SQLite, Oracle and a few others, each as its own package. What DbUp does not do is abstract the SQL: a script written for SQL Server is a SQL Server script.
FluentMigrator in practice#
The same first migration in FluentMigrator is a class:
using FluentMigrator;
namespace Migrations;
[Migration(20240101000001)]
public class CreateOwnersAndPets : Migration
{
public override void Up()
{
Create.Table("owners")
.WithColumn("id").AsInt32().NotNullable().PrimaryKey("pk_owners")
.WithColumn("first_name").AsString(50).NotNullable()
.WithColumn("last_name").AsString(50).NotNullable()
.WithColumn("address").AsString(255).Nullable()
.WithColumn("city").AsString(80).Nullable()
.WithColumn("telephone").AsString(20).Nullable();
Create.Table("pets")
.WithColumn("id").AsInt32().NotNullable().PrimaryKey("pk_pets")
.WithColumn("name").AsString(50).NotNullable()
.WithColumn("owner_id").AsInt32().NotNullable()
.ForeignKey("fk_pets_owners", "owners", "id");
}
public override void Down()
{
Delete.Table("pets");
Delete.Table("owners");
}
}
The version in the attribute is just a long; a timestamp is the common choice because it sorts and rarely collides between branches. Migrations run in version order and the applied versions land in VersionInfo.
You run them either in-process or with the CLI. In-process means registering the runner in a service collection, scanning the assembly for migrations and calling MigrateUp(), which fits a service that migrates its own database on startup. The CLI is a dotnet tool, dotnet-fm, and is the better fit for pipelines:
dotnet tool install -g FluentMigrator.DotNet.Cli
dotnet fm migrate -p sqlserver -c "Server=.;Database=petclinic;Trusted_Connection=True" -a Migrations.dll
dotnet fm rollback -p sqlserver -c "..." -a Migrations.dll --steps 1
Features you get on top of the basic history:
- Multiple providers from one codebase. The fluent description is rendered per provider, so the same migration assembly can target SQL Server in production and SQLite or PostgreSQL in tests. Where a difference cannot be hidden,
IfDatabase("Postgres")scopes a statement to one engine. - Raw SQL when you need it.
Execute.Sql(...)andExecute.EmbeddedScript(...)exist for the cases the fluent API does not express, such as a partial index or a check constraint with engine-specific syntax. - Down migrations and rollback.
Down()is part of the contract, and a base class exists for forward-only migrations when you decide rollback is not worth writing. - Tags and profiles. A migration can be tagged and run only for a matching tag, or marked as a profile that runs every time, which is how teams seed reference data or apply per-environment tweaks.
Side by side#
| Aspect | DbUp | FluentMigrator |
|---|---|---|
| Migration source | SQL scripts (embedded or on disk) | C# classes with a fluent API, plus raw SQL |
| Ordering | Script name (string order) | Numeric version in the attribute |
| History table | SchemaVersions | VersionInfo |
| Rollback | None built in; write a new forward script | Down() and rollback commands |
| Multi-engine | One script set per engine | One migration set rendered per provider |
| Transactions | Opt-in, per script or per run | Per migration by default, configurable |
| Runner | Your own console app or startup code | In-process runner or dotnet-fm CLI |
| Learning curve | Minutes | An afternoon |
Which one to choose#
When SQL-first wins#
DbUp is the right call when the people who design the schema are comfortable reading and writing the DDL of their engine and want to review exactly what will run. There is no translation layer to second-guess, a database administrator can read the pull request without learning an API, and engine-specific features (filtered indexes, computed columns, partitioning, WITH (ONLINE = ON)) are simply available. Rollback being absent turns out to matter less than it sounds: most teams that own production databases roll forward anyway, because a Down() that drops a column cannot bring the data back.
The cost is portability. If you ever need the same migrations to run on a second engine, you are maintaining two script trees.
When code-first wins#
FluentMigrator earns its extra weight in three situations. You ship software that customers install on the database they already have, so one history has to produce a correct schema on SQL Server, PostgreSQL and Oracle. You run an integration test suite against a lighter engine than production and want the migrations to be the same code. Or your team is more fluent in C# than in DDL and values compile-time checks, refactoring support and the ability to compute things in a migration. In each of those, describing the change once and letting the provider render it is a genuine saving.
Keep an eye on the translation, though. A fluent description of a column type is rendered differently per provider, and the differences (string lengths, date precision, identity semantics) are exactly where production surprises live. Reviewing the generated SQL, or drawing the resulting schema per engine, is worth the minute it takes.
Living next to EF Core#
Neither tool competes with EF Core as an ORM; the question is only who owns the schema. Three arrangements are common:
- EF Core owns everything. Migrations are generated from the model and applied with
dotnet ef database updateor a migration bundle. Simple, as long as everything you need to express fits the model. The EF Core migrations guide covers that path. - DbUp or FluentMigrator owns the schema, EF Core maps to it. The ORM is configured against an existing database and never generates migrations. Teams choose this when the schema predates the application, when several services share a database, or when the DBA wants SQL in the review.
- EF Core for the model, DbUp for what it cannot do. Views, stored procedures, full-text catalogs and seed data live in run-always DbUp scripts executed after the EF migration step. It works, but two histories on one database need a clear order in the deployment pipeline.
Whichever arrangement you pick, make one tool responsible for each object. Two tools that both believe they own a table will eventually disagree, and the disagreement shows up as schema drift between environments.
Where SchemaDrawer fits#
Both tools are supported by SchemaDrawer. Upload the .sql scripts of a DbUp project or the .cs classes of a FluentMigrator project, name the engine and version, and the migrations really run inside Docker against a throw-away database. The schema they produce comes back as an SVG diagram with tables, columns, primary keys and foreign keys. For FluentMigrator that is a quick way to see what the fluent description rendered to on PostgreSQL versus SQL Server; for DbUp it is a diagram that cannot go stale, because it is drawn from the scripts themselves. The DbUp and FluentMigrator pages list the accepted layouts and engines.
Key takeaways#
- DbUp runs your SQL once per script and journals it in
SchemaVersions; FluentMigrator runs versioned C# classes and journals them inVersionInfo. - Choose DbUp for full control of engine-specific SQL and a tiny surface area; choose FluentMigrator for one history across engines and code-level rollback.
- Turn on transactions deliberately in DbUp, and remember that MySQL commits DDL regardless of either tool.
- Next to EF Core, decide once which tool owns which objects, and order the steps in your pipeline accordingly.