September 20, 2026
.NET database migration tools compared: EF Core Migrations, DbUp, FluentMigrator, Flyway/Liquibase
Every .NET app with a real database needs a way to change that schema over time, in order, the same way in every environment. .NET has four commonly used tools for this, and they take genuinely different approaches — not just different syntax for the same idea.
EF Core Migrations
If you’re already using Entity Framework Core, its own migration system is usually the path of
least resistance: your DbContext and entity classes are the source of truth, and EF Core
generates the migration from the difference between your model and its last known snapshot.
dotnet ef migrations add AddOrdersTable
dotnet ef database update
Each migration is a C# class with Up() and Down() methods, checked into source control next
to the code that needs it. The main tradeoff: the migration is generated from your model, so a
model change and its migration can drift apart if someone edits one without the other, and the
generated SQL is sometimes not what you’d hand-write (adding a non-nullable column to a table with
existing rows, for instance, needs a default value EF Core won’t infer for you).
For deploying, dotnet ef migrations bundle --self-contained produces a single executable that
applies pending migrations against a connection string, without needing the EF Core tooling
installed on the target machine — the same artifact DotDeployer’s own “EF Core migration bundle”
mode runs.
DbUp
DbUp inverts the EF Core model: migrations are plain .sql files
(or C# scripts) numbered in order, and DbUp’s own job is narrow — track which ones have already
run, in a journal table it manages, and run whatever’s new, in filename order, inside a
transaction. There’s no generated migration and no dependency on any ORM; DbUp works identically
whether your app uses EF Core, Dapper, or raw ADO.NET.
var result = DeployChanges.To
.PostgresqlDatabase(connectionString)
.WithScriptsFromFileSystem("db")
.LogToConsole()
.Build()
.PerformUpgrade();
This is the model DotDeployer’s own Bot uses for its own schema (db/dotdeployer.NNN.*.sql,
numbered, journal-tracked) — the appeal is that the SQL is the SQL you’re actually going to run,
not something generated on your behalf, and reviewing a migration in a PR means reading SQL, not
a C# diff that implies SQL.
FluentMigrator
FluentMigrator sits between the two: migrations are C# classes like EF Core’s, but you write the schema change directly with a fluent API instead of it being generated from a model —
[Migration(202609200001)]
public class AddOrdersTable : Migration
{
public override void Up() =>
Create.Table("orders").WithColumn("id").AsInt32().PrimaryKey().Identity();
public override void Down() => Delete.Table("orders");
}
That fluent API also abstracts over database engines (SQL Server, PostgreSQL, SQLite, MySQL and others), useful if the same codebase genuinely needs to target more than one — something EF Core Migrations and DbUp don’t attempt in the same way.
Flyway and Liquibase
Flyway and Liquibase are both JVM-native tools with .NET-usable interfaces (a CLI you shell out to, or in Liquibase’s case a .NET library) rather than .NET-first tools. They’re the right pick mainly when a team already standardized on one of them across other stacks (Java, Node) and wants one migration tool company-wide rather than a .NET-specific one. Flyway follows DbUp’s philosophy — versioned SQL files, a journal table — while Liquibase supports SQL, XML, YAML or JSON changesets and a wider plugin ecosystem, at the cost of more moving parts for a team that’s purely .NET.
Seeding: a separate step from migrating
Seeding — inserting reference data (lookup tables, a default admin user, feature flags) rather
than changing schema — is a different operation and usually belongs in its own step, run after
migrations, because it often depends on tables the migration just created. DbUp supports this with
a separate script journal (Postgres’s own baseline-rebuild convention keeps seed data in one file,
migrations in another, so re-running seed data safely is a matter of INSERT ... ON CONFLICT DO NOTHING or similar, not blindly re-inserting on every deploy). EF Core’s equivalent is
modelBuilder.Entity<T>().HasData(...), which gets folded into a migration rather than kept
separate — fine for a handful of static rows, awkward for anything that changes often.
Npgsql connection pooling
Whichever migration tool you use, the app’s own database connections matter just as much day to
day. Npgsql pools connections by default — each
NpgsqlDataSource or DbContext pulls from an internal pool keyed by connection string, so a
request reuses an existing TCP connection to Postgres instead of opening a new one:
Host=127.0.0.1;Port=5432;Database=myapp;Username=myapp_user;Password=...;Maximum Pool Size=100;Minimum Pool Size=0
Maximum Pool Size=100 is Npgsql’s own default ceiling. On a small droplet running Postgres and
the app together, Postgres’s per-connection memory overhead (each backend process is a few MB)
becomes the real limit long before 100 pooled connections does — see the
PostgreSQL production setup article for the numbers.
For a workload with far more connection churn than that — many short-lived worker processes, or
serverless-style scale-to-zero compute — a dedicated pooler like
PgBouncer sits in front of Postgres and multiplexes many client
connections onto a smaller number of real backend connections; it’s rarely worth adding until
you’ve actually measured connection exhaustion, not as a default.
Running the migration as part of every deploy
Whichever tool you pick, the operational question is the same: does the migration run reliably, in order, before the new code that depends on it goes live? A migration run manually and inconsistently is the actual cause of most “works on my machine, breaks in production” schema incidents, not the tool itself. See deploy hooks for running an arbitrary command before a release goes live, and database migrations for DotDeployer’s dedicated migration step — an EF Core bundle, a shell command, or a custom command, run automatically on every deploy before the new release’s symlink is switched in.
Migrations run on every deploy, automatically
Set a migration mode once on a site's Database migrations tab -- EF Core bundle, DbUp, or any shell command -- and DotDeployer runs it before the new release goes live, every time.