If you do any work with Entity Framework, you quickly learn an important lesson: Migrations feel simple, and they are… until they aren’t. A bad migration, especially in production, leads to a really bad day. It’s important to do all you can to get it right from the start. A few tips and best practices can help you get there.

Name Your Migrations Like They Matter

Just as with class names and variable names, a proper name for your migration is critical. Using a name like “Fix” is meaningless in 6 months, much less a week from now. There are a number of conventions that are common, but it’s good to be descriptive:

  • AddUserTable
  • AddMiddleNameToUserTable
  • RemoveSalesDateFromClientOrderTable

You don’t need to go overboard, but provide enough description in the name so that the purpose of the migration is obvious and it’s easy to find the migration you’re looking for.

One Migration Per Change

Don’t group migrations together, unless they are very closely related. Keep the changes clearly separated from each other. You don’t want to mash together updates to the User table, the Sales table, and a new stored procedure that cleans up the log table. These are clearly separate concerns, and should be 3 separate migrations. Maintain the separation of concerns.

The reason for this is clear. If something goes wrong with one part of the migration, you only want to roll back the one change, not all 3. It also makes reviewing the code much easier.

Avoid Dangerous Migrations: Type, Size & Name Changes

While you’re getting to your MVP, changing the name, data type, or size of a column is ok. But once that MVP has been deployed to production and you start getting real data from real customer systems and users, avoid ANY change to name, type, or size. Period. The only exception might be if you’re increasing the size of a field to accommodate larger data. That’s it. That’s the only time you should even consider such changes.

The reason is straightforward. For these types of changes, the database must drop the existing field and recreate it as a new column. All of your existing data will in these columns will be lost. So avoid these types of changes. Instead, if a data type must absolutely change for a particular piece of data, you should create a new field with a different name and start using that one, leaving the old property in place and marked as deprecated.

If it’s necessary, EF Core does provide a method for safely renaming a database column without data loss: RenameColumn(). However, remember that if you have any stored procedures, views, functions, or other SQL elements that refer to that column anywhere, they won’t be updated and things will break. Use with care.

Never Edit An Existing Migration

EF Core does a lot in the background when you create a migration, and auto-generated files are created as part of this, including a model snapshot that EF Core uses to keep track of things in the background. If you manually manipulate the migration files, these things don’t get updated in the manner that they need to be. There are only two ways to fix things if a migration goes wrong.

dotnet ef migrations remove

Use this command to rollback the last migration that was generated. You can remove more than one migration, but these have to be done in reverse order of creation. You can’t pick and choose a specific migration somewhere back along the chain.

An important thing to remember with the remove command is it only removes the migration from your application. If you have already applied the changes to the database, you need to make sure you revert those changes in the database FIRST. You use the update command, passing in the name of the migration to roll back to (i.e. the one just before the one you want to revert).

dotnet ef database update <PreviousMigrationName>

Once the database is fixed, then you can remove the migration from the code. It’s important to do it in that order.

Create a New Migration

If it isn’t the last migration, and even if it was the last migration, it is often easier to just create a new migration that alters the schema in the direction you need it to go. It just depends on what schema changes were made that need to be corrected or reverted.

Always Review the Generated Migration

Don’t just rely on what EF Core does. EF Core makes guesses, and sometimes those guesses go wrong. For instance, it might try to rename a schema object that needs to be dropped and recreated. Or it might create indexes on the wrong fields. Review the file that gets created and make sure it is doing what you expect it to do in order to support the code changes you have made.

Remember also that the Down() method is just as important as the Up() method. If you do need to run the remove command, you need to trust that the rollback will perform the correct operations to undo what the migration originally did and not completely screw up your database.

Running Migrations From a Separate Data Project

It’s common practice to have your migrations and your DbContext class in a separate data project from the main running project, such as an API project. In cases like this, if you’re applying your migrations from the command line or console, you will need to pass in certain parameters to your dotnet ef command to help it find the migrations. Your migrations will fail to run if you don’t.

Run commands from the Data project directory, passing --startup-project pointing at the project that wires up the DbContext (usually the API):

# Add a migration (run from the Data project directory)
dotnet ef migrations add AddMiddleNameToUser --startup-project ../MyApp.ApiService

# Apply pending migrations
dotnet ef database update --startup-project ../MyApp.ApiService

# Remove the last migration
dotnet ef migrations remove --startup-project ../MyApp.ApiService

Auto-Apply Migrations on Startup

Applying migrations for production can be done as part of your CI/CD pipeline, but it’s also common practice to have your application auto-apply migrations during startup. There is a command on DbContext called MigrateAsync().

This command checks the migrations in your project and compares them against the log in your database to determine if any migrations have not yet been applied. If any are pending, it then applies those migrations before proceeding with the application startup process. You will typically apply this command either directly in your Program.cs file, or in another class called from there.

// Program.cs
var app = builder.Build();

// Apply pending migrations before the app starts accepting requests
using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    await db.Database.MigrateAsync();
}

await app.RunAsync();

Running the migrations during startup are not always the best approach, and there are situations where you would not want to take this approach.

  • High Availability & Multi-Instance Environments - If you run multiple instances of your project that might start simultaneously, these instances might conflict with one another, each trying to apply the migrations at the same time.
  • Restricted Database Permissions - The application will try to apply the migrations using the permissions assigned to the application. If those permissions do not include schema changes, which in many instances an application should not, the migrations will fail to run.
  • Large Databases or Long Running Migrations - If a migration could potentially take a long time to run (such as updating or adding an index to a very large table), this will delay the startup of your application, preventing the application from operating until the migration has completed.
  • Multi-Tenant Applications - Migrations that need to be applied across a large number of databases at once can fail and if an issue occurs, it can be difficult to recover from and address the issues in a manner that will keep things working correctly and allow future migrations to work correctly.

In cases where it doesn’t make sense to auto-apply migrations, what is the best approach to use? You have a couple of alternatives. The first one is migration bundles.

Alternative 1: Migration Bundles

A migration bundle is a single file executable introduced in EF Core 6 whose sole purpose is to implement a migration (or set of migrations). They can be generated for whatever environment the deployment will occur in, be it Windows or Linux.

Example:

dotnet ef migrations bundle --self-contained -r linux-x64

This will create an executable file named efbundle that has gathered up all the migration information in your database and can then be deployed and executed by your CI/CD pipeline, passing in the connection information to the database you wish to apply the changes to.

efbundle --connection <YOUR_DATABASE_CONNECTION_STRING>

It’s the equivalent of running update-database on your local machine, but entirely self-contained and not reliant on your application code being present.

Alternative 2: SQL Scripts

Another alternative is to generate SQL scripts. EF Core will generate SQL change scripts for you with the command:

dotnet ef migrations script

This will create a SQL script that can be used to generate your entire database from a blank start. If you want to generate scripts for only certain migrations, you can pass in the starting migration and, optionally, the ending migration to generate from. If you don’t pass in the ending migration, it will use every migration from the starting one to the last one.

dotnet ef migrations script AddLastLoginDateToUserTable AddArchivedFlagToSalesRecordTable

You use just the same names that you used with your add-migration command, not including the auto-generated datestamps that EF Core adds.

An important flag to know for the script command is --idempotent. It will wrap each script in a check against the __EFMigrationsHistory table so that it doesn’t try to reapply migrations that it has already run.

dotnet ef migrations script --idempotent

This lets you safely run the script repeatedly if needed.

Seeding Data the Right Way

If you need to seed data into a table, be it prepared data or testing data of some sort, it’s important to use the right approach to doing so. There are a couple of effective methods for doing so, depending on what you’re trying to do.

Seeding Test Data - The Wrong Way

If your goal is to seed test data in a local or test environment, and you’re using EF Core migrations, the wrong approach is to use the UseSeeding() or UseAsyncSeeding() function in your OnConfiguring. These functions are then called by the EnsureCreated() and EnsureCreatedAsync() functions during startup. Remember that best practice is to only call these functions when in a “development” or “test” environment, and not in production.

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder.UseNpgsql(connectionString)
        .UseAsyncSeeding(async (context, _, ct) =>
        {
            if (!await context.Set<User>().AnyAsync(ct))
            {
                context.Set<User>().AddRange(
                    new User { Name = "Alice", Email = "alice@example.com" },
                    new User { Name = "Bob",   Email = "bob@example.com"   }
                );
                await context.SaveChangesAsync(ct);
            }
        });
}

And then in Program.cs, guard the seeding so it only runs outside of production:

if (app.Environment.IsDevelopment())
{
    using var scope = app.Services.CreateScope();
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    await db.Database.EnsureCreatedAsync();
}

One important thing to remember with this method of seeding data. EnsureCreated() and EnsureCreatedAsync() bypass the migrations process completely. They simply force the database schema to match your data model directly, ignoring your migrations and whatever may be in the __EFMigrationsHistory table. If you later switch to using MigrateAsync(), it will break and fail on an existing database. It is presented here as an option.

So, what’s a better way for seeding test data?

Seeding Test Data - A Better Approach

The best approach is to create a dedicated seeder class that you can execute after MigrateAsync() is run. This keeps your EF Core migrations operating correctly and avoids the incompatibility with EnsureCreated running rampant all over your database.

public static class TestDataSeeder
{
    public static async Task SeedAsync(AppDbContext db)
    {
        if (!await db.Users.AnyAsync())
        {
            db.Users.AddRange(
                new User { Name = "Alice", Email = "alice@example.com" },
                new User { Name = "Bob",   Email = "bob@example.com"   }
            );
            await db.SaveChangesAsync();
        }
    }
}
// Program.cs
if (app.Environment.IsDevelopment())
{
    using var scope = app.Services.CreateScope();
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    await db.Database.MigrateAsync();  // apply migrations first
    await TestDataSeeder.SeedAsync(db); // then seed
}

This approach is a plain C# class, making it testable, easy to extend, and doesn’t interfere with the migrations work. And you can easily use techniques like AnyAsync() to ensure test data doesn’t get duplicated.

Model Managed Data

If your goal is not to seed test data, but rather to ensure that a table is populated with certain data elements, then the proper approach is to use the HasData() function of ModelBuilder in your OnModelCreating function.

For instance, if you have a Countries table and want to pre-populate it with a list of the world’s countries, you would take this approach.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Country>().HasData(
        new Country { Id = 1, Name = "United States", Code = "US" },
        new Country { Id = 2, Name = "Canada",        Code = "CA" },
        new Country { Id = 3, Name = "United Kingdom", Code = "GB" }
    );
}

EF Core tracks this data by primary key, so any addition, update, or removal here gets emitted as SQL in the next migration you generate.

There are a couple of things to keep in mind. First, the primary key must be specified in the HasData() function, even if it’s set as auto-generated in the database. This is used to determine if any data has changed since the last startup.

Second, if you change the primary key value at any point, the previous version will be deleted and the new one inserted. So once it’s added, never change the primary key for any row. Period.

And lastly, if you altered the data in that table by any other means, and you later make changes in HasData(), those other changes could be overwritten.

Squashing Migrations

If, for some reason, you want to merge multiple migrations together, you can certainly do so. But just because you can do a thing doesn’t mean you should do a thing. There may be a valid reason you would want to. I can’t think of one, but you might. But you’ll certainly want to ensure that if you have already deployed changes, that the new squashed migration doesn’t get applied to those environments. The result could be a simple failure to apply, or it could completely destroy the existing database schema.

The approach here is to trick EF Core a bit. Use the following process:

  1. Create a new empty migration with no changes. Keep a copy of the name of that migration with the timestamp somewhere. You’ll need it in a moment.
  2. Apply that migration to all environments.
  3. Delete your existing migrations from your solution.
  4. Create a new migration with the same name as step 1.
  5. Once the new migration is created, alter all references to it to match the timestamp from step 1. Make sure you change both the filenames and the names in code (including in auto-generated hidden code files).

Poof. You now have a squashed migration that will not get applied in existing environments, but will be applied in any new environments that get created down the road.

Check For Pending Migrations In Production

Even if you don’t auto-apply migrations in your application, you should include a check for pending migrations as a part of your startup health checks. This can be done using the GetPendingMigrationsAsync() command of your DbContext instance.

var pending = await db.Database.GetPendingMigrationsAsync();

You can wire this into ASP.NET Core’s health check system so a monitoring tool or load balancer will catch it immediately:

// Program.cs
builder.Services.AddHealthChecks()
    .AddCheck<MigrationHealthCheck>("migrations");
public class MigrationHealthCheck(AppDbContext db) : IHealthCheck
{
    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context,
        CancellationToken ct = default)
    {
        var pending = (await db.Database.GetPendingMigrationsAsync(ct)).ToList();

        return pending.Count == 0
            ? HealthCheckResult.Healthy()
            : HealthCheckResult.Degraded($"{pending.Count} pending migration(s): {string.Join(", ", pending)}");
    }
}

This will help you add logic to tell you that migrations were not applied that should be.

Conclusion

In the end, EF Core migrations are just SQL scripts in disguise. The code gets translated into SQL that gets applied to the database when it runs. It’s important that as a developer you understand how that C# code gets translated into SQL, and what it does. By doing so, you can better understand and catch instances where EF Core goes wrong with migrations. And that, in the end, is the skill.