Entity Framework Core is Microsoft's ORM for .NET. The single feature that trips up everyone learning EF Core is migrations — the mechanism that keeps your database schema in sync with your C# classes as they evolve. This article walks you through migrations from first principles to production: dotnet ef init, schema changes, merging, and zero-downtime deploys.
What is an ORM, briefly?
ORM stands for Object-Relational Mapper. It is a library that lets you write database queries in your application language (C# in our case) using your application's objects, instead of writing raw SQL. EF Core is Microsoft's ORM for .NET. You define your data model as C# classes, EF Core generates the SQL to create matching tables, and you query using LINQ.
You give up some control (EF Core may not generate the optimal SQL for every query) and gain a lot of speed (no boilerplate SQL, automatic mapping, change tracking). For most apps, the trade is worth it.
Defining your model
An EF Core model is a plain C# class with properties:
public class Article
{
public int Id { get; set; }
public string Title { get; set; } = "";
public string Slug { get; set; } = "";
public DateTime PublishedAt { get; set; }
public bool IsPublished { get; set; }
public List<Tag> Tags { get; set; } = new();
}
public class Tag
{
public int Id { get; set; }
public string Name { get; set; } = "";
}
By convention, a property named Id or <ClassName>Id becomes the primary key. Navigation properties (like Article.Tags) become foreign keys. You can override these defaults with attributes or fluent configuration, but convention covers most cases.
The DbContext
Every EF Core project has a DbContext — the gateway to the database:
public class AppDb : DbContext
{
public AppDb(DbContextOptions<AppDb> options) : base(options) {}
public DbSet<Article> Articles => Set<Article>();
public DbSet<Tag> Tags => Set<Tag>();
}
Each DbSet<T> maps to a table. You register the context in Program.cs with a connection string:
builder.Services.AddDbContext<AppDb>(o =>
o.UseSqlite("Data Source=app.db"));
EF Core supports SQL Server, PostgreSQL, MySQL, SQLite, and Cosmos DB out of the box. Each provider has slightly different feature support; check the docs when in doubt.
Your first migration
Install the EF Core CLI:
dotnet tool install --global dotnet-ef
Then, with the project compiled, generate a migration:
dotnet ef migrations add InitialCreate
That creates a folder called Migrations/ with two files: a designer file (C# code that builds the model) and a migration file (the actual changes — table creation, column types, indexes). Open the migration file and read it. You will see CreateTable calls for every entity.
Apply the migration to the database:
dotnet ef database update
EF Core compares the migration to the current database, generates the necessary SQL, and runs it. You now have a working database that matches your model.
Changing the schema
Add a property to Article:
public class Article
{
// ... existing properties ...
public string? Excerpt { get; set; }
}
Then:
dotnet ef migrations add AddArticleExcerpt
dotnet ef database update
The new migration contains a single AddColumn call. EF Core detects the difference between the current model and the previous migration's snapshot and generates the SQL to bridge them. You can review every migration before applying it; the C# code is human-readable.
Removing migrations
If you have not yet applied a migration to production, you can remove the last one with:
dotnet ef migrations remove
That deletes the most recent migration files and reverts the model snapshot. Use it freely during development. Once a migration has shipped, never remove it. Always add a new one.
The migration workflow in a team
When multiple developers work on the same project, migrations get messy fast. The standard workflow:
- Developer A creates migration "AddExcerpt".
- Developer B, on the same branch, creates migration "AddTags".
- Both migrations exist in source control.
- Developer B pulls, gets both migrations, applies them in order.
The pitfalls:
- Two migrations touching the same column. One of them will fail to apply. Resolve manually or have one developer regenerate after the other is committed.
- Migrations applied out of order. Each migration has a timestamp. EF Core applies them in order. If you jump the order, you get a corrupt database.
- Schema drift between dev and prod. The discipline: every schema change goes through a migration, applied to staging first, then production.
Production deploys: zero-downtime migrations
The naive approach — run dotnet ef database update as part of your deploy — works for small projects and breaks for anything bigger. Two reasons:
- The deploy takes the app offline while the migration runs.
- The new app version assumes the new schema, but old app instances still expect the old one. During a rolling deploy, both versions are live.
The fix is the "expand-contract" pattern:
- Expand — add the new column nullable, deploy both old and new app versions.
- Migrate — write data into the new column, backfilling any old rows.
- Contract — once all rows are migrated, remove the old column in a later deploy.
Each step is its own migration and its own deploy. The app is never in an inconsistent state. The pattern takes more discipline but is the difference between "we have downtime" and "we have a real service."
Indexes and constraints in migrations
EF Core can manage indexes and constraints as part of migrations. The fluent API in OnModelCreating:
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<Article>()
.HasIndex(a => a.Slug)
.IsUnique();
b.Entity<Article>()
.HasIndex(a => a.PublishedAt);
b.Entity<Article>()
.HasOne(a => a.Author)
.WithMany(u => u.Articles)
.HasForeignKey(a => a.AuthorId)
.OnDelete(DeleteBehavior.Cascade);
}
The indexes will appear in the next migration as CreateIndex calls. The foreign key cascade will appear as part of the column declaration. Configure these once in OnModelCreating, and migrations will keep them in sync forever.
Composing complex queries with LINQ
Once your schema is set, you write queries in LINQ:
var published = await db.Articles
.Where(a => a.IsPublished)
.OrderByDescending(a => a.PublishedAt)
.Take(10)
.Include(a => a.Tags)
.ToListAsync();
That single statement translates to a SQL query with joins, a WHERE clause, an ORDER BY, and a LIMIT. EF Core parses the LINQ expression tree and emits efficient SQL. The Include method tells EF Core to load related entities in the same query (eager loading), avoiding the N+1 problem.
For pagination:
var page = await db.Articles
.OrderBy(a => a.Id)
.Skip((pageNum - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
Combine with a CountAsync for the total and you have the classic offset-paginated list endpoint from our REST API article.
Common pitfalls
- Editing migration files after the fact. Once a migration is committed, treat it as immutable. Make a new migration instead.
- Forgetting to add
DbSetfor new entities. EF Core only knows about entities that have aDbSetproperty on the context. A class without one will not become a table. - Not seeding reference data. A migration can also insert seed data. Use
modelBuilder.Entity<T>().HasData(...)inOnModelCreating. - Big migrations in one step. Break large schema changes into multiple smaller migrations. Easier to review, easier to roll back.
- Not testing the SQL. Print the SQL EF Core generates with
dotnet ef migrations script. Verify it matches what you expected before applying to production.
A more advanced thing: raw SQL when you need it
Sometimes EF Core's LINQ is not expressive enough for what you need. Use raw SQL:
var recent = await db.Articles
.FromSqlRaw("SELECT * FROM Articles WHERE PublishedAt > {0}", cutoff)
.ToListAsync();
Or for an update:
await db.Database.ExecuteSqlRawAsync(
"UPDATE Articles SET Views = Views + 1 WHERE id);
For bulk operations (deleting 100,000 rows), EF Core can be slow. Drop to ExecuteSqlRawAsync or use a library like EFCore.BulkExtensions. Profile before optimising — most queries are fine.
Further reading
EF Core migrations are simple once you have the mental model. These docs build that model.
- Microsoft Learn: EF Core Migrations — The canonical tutorial, covers init through to production deploy.
- Microsoft Learn: EF Core Modeling — How the conventions and attributes translate your C# classes into a schema.
- dotnet/efcore on GitHub — Release notes and the issue tracker — useful when a migration behaves oddly.
FAQ
Do I need migrations if I am using SQLite?
EF Core supports migrations on SQLite, but for tiny apps you can also delete the database file and recreate it from scratch on each deploy. SQLite databases are cheap to recreate. For anything larger, use migrations.
Can I edit a migration after I have applied it?
Locally, yes. In production, no — every applied migration is part of history. Reverse it with a new migration.
How do I rollback a migration in production?
dotnet ef database update <PreviousMigrationName>. This runs the down-script for the bad migration. Always test the rollback on staging first. For zero-downtime, do not roll back; ship a fix forward instead.
Should I use EF Core or Dapper?
EF Core for app code with moderate query complexity. Dapper for read-heavy, performance-critical, or query-heavy workloads. You can mix them — use EF Core for writes, Dapper for the hot reads.
What is the migration history table?
EF Core creates a __EFMigrationsHistory table in your database that records which migrations have been applied. Do not delete rows from it manually.
How do I deploy migrations safely?
Generate the SQL with dotnet ef migrations script, review it, and run it manually in production as part of your release process. Do not run dotnet ef database update against production from a CI runner unless you are sure no deploy races can occur.
How do I add an index to an existing table?
Add it to OnModelCreating (or via an attribute), then generate a new migration. EF Core will detect the new index and emit a CreateIndex operation in the migration. Apply as usual.
The discipline of running each migration locally before applying it anywhere else will save you from the worst-case scenarios down the road.
The discipline of running each migration locally before applying it anywhere else will save you from the worst-case scenarios that inevitably appear down the road in production environments.
Homework
Build a small blog schema in EF Core and evolve it:
- Define
Article,Tag, andAuthorclasses with relationships. - Generate and apply the initial migration.
- Add a
PublishedAtcolumn toArticlewith a new migration. - Add a many-to-many between articles and tags with another migration.
- Seed the database with three authors and ten tags.
- Practice the expand-contract pattern: add a
NewTitlecolumn nullable, then drop the oldTitlecolumn.
By the end you will have applied five migrations cleanly and have the muscle memory for production deploys. If you get stuck, the official EF Core docs have every detail.