Migrating Database Changes with Entity Framework Core

A database schema rarely stays still once an application enters active development. New features introduce tables, columns, indexes, relationships and constraints, while existing data must remain available to users. Entity Framework Core (EF Core) migrations provide a structured way to record those changes and apply them consistently across a developer workstation, a test environment and production.

For an Australian development team, that consistency matters when an application is maintained across Sydney, Melbourne, Brisbane or remote locations. It also matters around busy periods such as the end of the financial year, when an avoidable deployment failure can disrupt reporting, invoicing or customer support. A sound migration process turns database evolution into a reviewable part of the application’s source code rather than a collection of manual SQL scripts.

How EF Core Migrations Work

EF Core compares the current model defined by your entity classes and configuration with a snapshot of the model from the previous migration. When you create a migration, it generates code describing the differences. That code can add a table, rename a property, create an index, alter a column or remove an object from the database.

A migration usually contains two methods: Up, which applies the change, and Down, which attempts to reverse it. EF Core also maintains a model snapshot and records applied migrations in a history table, commonly named __EFMigrationsHistory. This gives the framework a record of which changes have already been run against a particular database.

The migration is a description of an operation, not a copy of the entire database. EF Core does not automatically inspect every production value and make a perfect decision about how existing data should be transformed. That distinction becomes important when a property is renamed, a column becomes required, or a table must be split into several related tables.

Preparing A Reliable Model

Before creating a migration, make the entity model explicit. Configure keys, foreign keys, maximum lengths, required properties, decimal precision and indexes through data annotations or the Fluent API. Ambiguous conventions can produce surprising schema changes, particularly when nullable reference types or naming conventions change between framework versions.

A DbContext should usually be configured through dependency injection, with its connection string supplied by the relevant environment. Keep the context and migrations in a predictable project, and make sure design-time tooling can construct the context. If the context lives in a class library while the application starts in a web project, specify both the project containing migrations and the startup project when running commands.

A practical initial setup might look like this:

dotnet ef migrations add InitialCreate \
  --project Data \
  --startup-project Web

dotnet ef database update \
  --project Data \
  --startup-project Web

The first command creates migration files; the second applies pending migrations to the configured database. Do not run database update against a production connection string from a developer laptop. Local development should use an isolated database, and the connection string should be supplied through user secrets, environment variables or a managed configuration service.

Reviewing Generated Schema Changes

Generated migration code deserves the same review as application code. Check the table and column names, data types, nullability, default values, indexes and foreign-key actions. A migration that appears to rename a property may actually drop the old column and create a new one if EF Core cannot identify the intended rename.

For example, changing Name to DisplayName in an entity can result in destructive operations. If the data must be retained, edit the migration so it uses a rename operation:

migrationBuilder.RenameColumn(
    name: "Name",
    table: "Customers",
    newName: "DisplayName");

Inspect generated SQL before applying a high-impact change. The command below creates a script containing the migration operations:

dotnet ef migrations script \
  --project Data \
  --startup-project Web \
  --idempotent \
  --output deploy.sql

An idempotent script checks the migration history and applies only migrations that are missing. It can be useful when the exact version of a customer database is not known, although the script still requires careful testing. A review should also consider table size, available indexes, locks and the likely duration of an alteration on real data.

Separating Schema And Data Changes

Schema changes and data transformations often need different treatment. Adding a nullable column is usually straightforward, while adding a non-nullable column to a populated table requires a default, a backfill or a staged rollout. Applying a required constraint before every existing row has a valid value can make the deployment fail.

A safer sequence is often to add the new column as nullable, deploy application code that can write both the old and new values, backfill existing rows in manageable batches, and then enforce the final constraint in a later migration. This expand-and-contract approach reduces downtime and allows the application and database to remain compatible while the release is in progress.

Small data updates can be included in a migration with migrationBuilder.Sql, but large updates may need a dedicated script or background job. Batch the work, monitor transaction duration and consider the impact on indexes and replication. A migration should be repeatable in the sense that it runs once through EF Core’s history tracking; the SQL inside it must still be carefully designed for the volume and shape of the data.

The same caution applies to seeded data. HasData is intended for stable reference values, such as status codes or fixed categories. It is less suitable for records that administrators edit or for large datasets. Application-level seed routines can provide better control, provided they are idempotent and protected against concurrent execution.

Applying Migrations Across Environments

Development databases can usually be updated automatically, but production database changes deserve a controlled deployment step. Running migrations during application startup may seem convenient, yet it can cause several application instances to compete for the same change. A slow migration can also delay health checks or prevent a service from starting.

A CI/CD pipeline can build the application, generate or validate the migration script, apply it using a dedicated deployment identity and then release the application. Keep database credentials separate from application credentials where possible. Record who applied the change, which migration was used and how long it took.

Australian teams may schedule a production change in an agreed AEST or AEDT window so engineers in Perth, Adelaide and the eastern capitals know exactly when monitoring begins. Check public holidays and operational events such as EOFY reporting before selecting a window. A migration that is harmless on a quiet Tuesday can be much riskier during a retailer’s seasonal campaign or a government service deadline.

Test migrations against a recent, anonymised copy of production data when the system permits it. A small local database will not reveal a table-lock problem that appears after millions of rows. Capture execution time, query plans and resource usage, and verify that the application can read and write successfully after the migration completes.

Handling Rollbacks And Failed Deployments

The Down method can reverse many structural operations, but rollback is not a universal safety net. Once a migration has deleted data or transformed it irreversibly, reversing the schema does not restore the original values. Before a destructive change, take a verified backup and establish how restoration will be performed.

For production systems, a forward fix is often safer than rolling back. If a migration adds an incorrect index or column, create a new migration that corrects the problem. If application code fails after a schema change, deploy compatible code rather than immediately removing the schema. This approach preserves the migration history and reduces the chance of two environments drifting apart.

When a deployment fails, identify whether the failure occurred before, during or after the migration transaction. Many operations are transactional, but database providers differ, and some schema operations cannot be rolled back in the same way. Inspect the migration history table, database logs and pipeline output before retrying.

Schema changes also need coordination with application versions. A new application should generally work with the previous schema during a rolling deployment, and the previous application should tolerate the expanded schema. This compatibility window is especially useful when container instances are replaced gradually or when an Australian hosting provider performs maintenance across availability zones.

Testing, Diagnostics And Team Collaboration

Migration tests should cover the database provider used in production. SQLite can be useful for fast tests, but it does not reproduce every SQL Server behaviour, including data types, locking and certain constraint rules. For a SQL Server application, run integration tests against SQL Server in a disposable container or a dedicated test database.

Use a clean database test to verify that all migrations can build the current schema from nothing. Use an upgrade test to start with an earlier migration, load representative data and apply the new migration. Then test the application’s critical paths, including authentication, reporting, writes and background processing. These tests catch problems that a simple successful command may miss.

When a team is diagnosing a migration workflow, small tools can be valuable for tracking issues, release notes and recurring deployment errors. A related example is this Python issue tracker, which illustrates how a focused utility can make project maintenance more visible without becoming part of the main application.

Keep migration files in source control and avoid editing a migration that has already been applied to a shared environment. If two developers create migrations from the same starting point, merge them deliberately. EF Core may need a merge migration or a recreated migration sequence, depending on whether either branch has reached a shared database.

A Practical Migration Workflow

A repeatable process reduces surprises and gives reviewers useful evidence before production. Treat the migration as part of a release, rather than as an incidental result of changing a C# class.

Use the following workflow for each database change:

This process can be adapted to a small consultancy, an internal platform team or a large Australian SaaS product. Smaller teams may use a manually approved SQL script, while larger organisations may require peer review, change records and automated drift checks. The underlying principle remains the same: the database is a production dependency, and its evolution needs observable, tested steps.

Entity Framework Core migrations work best when they remain boring. Keep them focused, avoid hiding major data operations inside application startup, and make destructive actions visible during code review. With a compatible deployment strategy and realistic test data, schema changes become a manageable part of delivering software.

Start by reviewing the next migration your project plans to generate. Compare it with the existing schema, test it against realistic data and document the deployment path before the change reaches production. That small discipline can prevent lost data, extended outages and rushed fixes when the application is already serving customers.