DbUp vs FluentMigrator: Two Ways to Migrate .NET Databases

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(...) and Execute.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#

AspectDbUpFluentMigrator
Migration sourceSQL scripts (embedded or on disk)C# classes with a fluent API, plus raw SQL
OrderingScript name (string order)Numeric version in the attribute
History tableSchemaVersionsVersionInfo
RollbackNone built in; write a new forward scriptDown() and rollback commands
Multi-engineOne script set per engineOne migration set rendered per provider
TransactionsOpt-in, per script or per runPer migration by default, configurable
RunnerYour own console app or startup codeIn-process runner or dotnet-fm CLI
Learning curveMinutesAn 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:

  1. EF Core owns everything. Migrations are generated from the model and applied with dotnet ef database update or a migration bundle. Simple, as long as everything you need to express fits the model. The EF Core migrations guide covers that path.
  2. 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.
  3. 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 in VersionInfo.
  • 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.
Marek Hudyma
Builds SchemaDrawer, a service that draws database diagrams by running your migrations on a real engine. About

Draw your schema

Upload your migration files, pick the engine version you deploy to, get an SVG back. Free while in early access.

Get started
Keep reading

Related articles

Draw your schema in the next five minutes

One HTTP call with your migration files, one poll, one SVG. Free while we build it.