Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 016 / 100

EF Core migrations in a team: a .NET guide for developers

Migrations are code. Treat them like code.

Expona Admin8 min read

EF Core migrations in a team: More than just database changes

If you've ever spent an hour resolving a merge conflict in a migration file, or watched production go down because multiple instances tried to migrate the database simultaneously, you know that EF Core migrations in a team environment need discipline. Migrations aren't just database DDL—they're code that lives in your repository, affects your deployment pipeline, and can silently break your application if mishandled. I've learned this the hard way across multiple platforms, and I want to share what actually works at scale.

One migration per PR: Keep them small and reviewable

The most common mistake I see is bundling multiple unrelated schema changes into a single migration. Maybe you're adding a new column, renaming an old one, and creating an index—all in one shot. When that migration hits a rebase conflict, you're debugging snapshot mismatches instead of reviewing database changes.

Here's the principle: one logical change per pull request, one migration per PR. If you're adding a `LastLoginAt` column to the `User` entity and creating an index on `Email`, that's two separate migrations.

Why? Code review becomes straightforward. A reviewer can see exactly what changed in the model, what SQL will execute, and spot issues like missing constraints or inefficient queries. Merge conflicts shrink dramatically because your migration files touch fewer lines. When you need to roll back a specific change in production, you're rolling back a focused, tested migration—not a grab-bag of unrelated schema alterations.

In practice, after modifying your DbContext:

csharp
dotnet ef migrations add AddUserLastLoginColumn

That's it. One migration per feature. Your teammates will thank you during code review.

Review the generated SQL: Don't trust the scaffold blindly

EF Core's migration generator is powerful, but it's not magic. I've seen it generate `DROP TABLE` statements when a simple `RENAME COLUMN` would suffice, or create indexes that duplicate existing ones. The fix is simple: always review the generated SQL before committing.

Run the migration script and read it:

csharp
dotnet ef migrations script --idempotent -o migrate.sql

The `--idempotent` flag generates a script that can run multiple times safely (useful if a migration partially fails). Open `migrate.sql` and read every line. Ask yourself:

  • Are there any `DROP` statements I didn't expect?
  • Are indexes being recreated unnecessarily?
  • Will this lock a table for too long on a large production database?
  • Are foreign key constraints dropping and recreating (causing cascading changes)?

I once caught a migration that was dropping and recreating a `User` table just to rename a column. In production, that would have deleted every user record. The generated migration code looked correct—but the SQL was catastrophic. That's why you review.

If the generated migration isn't optimal, edit it directly. EF migrations are just C# code:

csharp
public partial class RenameUserEmailColumn : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        // Bad (what EF might generate):
        // migrationBuilder.DropColumn("Email", "Users");
        // migrationBuilder.AddColumn("UserEmail", "Users");
        
        // Good (what you should actually do):
        migrationBuilder.RenameColumn("Email", "Users", "UserEmail");
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.RenameColumn("UserEmail", "Users", "Email");
    }
}

Manual edits to migrations are perfectly fine and sometimes necessary. Just make sure your Down() method is correct so you can safely rollback if needed.

Never auto-migrate on startup in production: Multiple instances will race

I see this in nearly every new team I join: a startup hook that calls `context.Database.Migrate()` on application boot. It seems convenient, but it's a landmine in production.

When you deploy multiple instances of your application simultaneously (or during a rolling deployment), they all try to acquire the migration lock and apply pending migrations. Usually the database lock prevents corruption, but you get thundering herd behavior: one instance acquires the lock, applies migrations, releases it, and then nine other instances wake up and hit a fully-migrated database. More importantly, if a migration fails partway through, your instances are in an inconsistent state—some seeing the old schema, others seeing partial schema.

Even worse, if you're running Kubernetes and spin up a new pod before the previous one drains, both are fighting for the migration lock. Your deployment becomes fragile.

Do not auto-migrate in production. Migrations should be a separate, controlled deploy step.

Options:

  • Pre-deployment script: Run migrations before bringing up new instances. Your deployment pipeline executes `dotnet ef database update` once, waits for success, then deploys the application.
  • Migration bundle: (See next section) Package migrations as a self-contained executable and run it as a separate step.
  • Manual trigger: A sysadmin or DevOps engineer explicitly runs migrations after reviewing the generated SQL.

For local development and testing, `context.Database.Migrate()` on startup is fine. But production? Keep migrations out of your application startup path.

Use migration bundles: Self-contained executables for CI/CD

.NET 6+ introduced migration bundles, and they're a game-changer for team workflows. A migration bundle is a self-contained executable that includes your entire EF Core stack, your DbContext, and all pending migrations. You run it once, outside of your application process, and it handles everything.

Generate a bundle:

csharp
dotnet ef migrations bundle -o efbundle

This creates `efbundle` (or `efbundle.exe` on Windows)—a small executable that includes everything needed to migrate your database. No .NET SDK required on the target server, no shared libraries, no surprises.

In your CI/CD pipeline (GitHub Actions, GitLab CI, whatever):

csharp
steps:
  - name: Generate migration bundle
    run: dotnet ef migrations bundle -o efbundle
  
  - name: Upload artifact
    uses: actions/upload-artifact@v3
    with:
      name: efbundle
      path: efbundle
  
  # Then, in your deployment job:
  - name: Run migrations
    run: ./efbundle --connection "${{ secrets.DB_CONNECTION_STRING }}"

The bundle is portable, reproducible, and easy to audit. You can run it on a bastion server, in a Docker container before your app starts, or even manually verify it before deploying to production. It's the cleanest way to separate schema changes from application deployment.

Expand, then contract: Zero-downtime schema changes

This pattern is crucial when you're running production systems with zero-downtime deployments. If you need to rename a column or significantly restructure a table, doing it in one migration causes a brief outage while the schema lock is held. Instead, use the expand-then-contract pattern:

  1. Expand (Migration 1): Add the new column alongside the old one. No code changes yet.
  2. Deploy code (Deployment 1): Update your application to write to both columns, read from the new column, but fall back to the old column if needed.
  3. Backfill (Manual step or Migration 2): Copy data from old column to new column for any existing records.
  4. Switch code (Deployment 2): Update your application to only write to the new column, remove all fallback logic.
  5. Contract (Migration 3): Drop the old column.

This sounds like overkill for a hobby project, but at scale—especially with large tables—this prevents downtime and gives you a graceful escape hatch if something goes wrong.

Example: Renaming `Email` to `UserEmail` on a table with 10 million rows:

Migration 1 (Expand):

csharp
public partial class AddUserEmailColumn : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.AddColumn<string>(
            name: "UserEmail",
            table: "Users",
            type: "nvarchar(255)",
            nullable: true);
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropColumn("UserEmail", "Users");
    }
}

Code in your DbContext (Deployment 1):

csharp
public class User
{
    public int Id { get; set; }
    
    [Obsolete("Use UserEmail instead")]
    public string Email { get; set; }
    
    public string UserEmail { get; set; }
}

// In your data access layer:
public async Task<User> GetUserByEmail(string email)
{
    var user = await context.Users.FirstOrDefaultAsync(u => u.UserEmail == email);
    return user ?? await context.Users.FirstOrDefaultAsync(u => u.Email == email);
}

Migration 2 (Backfill data):

csharp
public partial class BackfillUserEmail : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql("UPDATE Users SET UserEmail = Email WHERE UserEmail IS NULL");
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql("UPDATE Users SET UserEmail = NULL");
    }
}

Code in Deployment 2: Remove the fallback logic, write only to `UserEmail`.

Migration 3 (Contract): Drop the `Email` column.

This approach works for column renames, type changes, and table restructuring. You sacrifice speed for safety and zero downtime, which is a worthwhile trade in production environments.

Common mistakes

  • Committing migrations without reviewing the SQL: Always run `dotnet ef migrations script` and read the generated DDL before pushing code.
  • Assuming migration bundles work offline: Bundles still need database connectivity; they just don't require the .NET SDK.
  • Mixing schema changes with data seeding in the same migration: Keep them separate. Schema changes are deterministic; data operations can be fragile.
  • Forgetting to add indexes on foreign keys: EF won't do this automatically. Your queries will suffer performance regressions.
  • Running migrations during deployment without a rollback plan: Always have a Down() method that works, and test it locally.
  • Using auto-generated migration names like "Initial": Name migrations descriptively: `AddUserLastLoginColumn`, `RenameOrderStatusToOrderState`. Future you will be grateful.

Key takeaways

  • Treat migrations as code: one logical change per PR, reviewable and testable.
  • Always inspect the generated SQL with `dotnet ef migrations script --idempotent` before committing.
  • Remove auto-migration from your application startup in production; use a separate, controlled deploy step.
  • Use migration bundles for portable, reproducible schema deployments in CI/CD pipelines.
  • For large, critical tables, use expand-then-contract to achieve zero-downtime schema changes.
  • Test your Down() method locally; you will need it.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles