Start Debugging
2026-07-08 Updated 2026-07-08 migrationefcoredotnet Edit on GitHub

Migrate from HasData seeding to UseAsyncSeeding in EF Core 11

A step-by-step guide to moving seed data off HasData and onto UseSeeding and UseAsyncSeeding in EF Core 11, including the DeleteData migration trap that wipes your existing rows if you skip it.

Moving seed data from HasData to UseSeeding/UseAsyncSeeding in EF Core 11 is a half-day job for a typical app, and the one thing that will bite you is not the new API: it is that deleting a HasData call makes the next dotnet ef migrations add emit a DeleteData statement that removes those exact rows from every database the migration runs against. If you delete the HasData and add the UseSeeding in the same deployment without thinking about ordering, you can wipe reference data in production. This guide walks the migration in the order that avoids that, using .NET 11, EF Core 11 (Microsoft.EntityFrameworkCore 11.0), and C# 14. Budget an afternoon for a medium app, most of it spent auditing which seeds should have moved years ago.

Is it worth it? For anything dynamic, key-generated, computed, or conditional: yes, and it was overdue. For a genuinely static three-row lookup table, leave it on HasData. The goal here is not “delete every HasData”, it is “move the seeds that never belonged in the model.”

Why move off HasData at all

HasData bakes your seed data into the model snapshot. That single design fact is the root of every reason to leave:

The EF team renamed HasData to “model-managed data” precisely to discourage its use as a general seeding tool. UseSeeding and UseAsyncSeeding, introduced in EF Core 9 and current in EF Core 11, are plain application code that runs against a live DbContext, with none of those limits. If you are still deciding which seeds to move, the HasData vs UseSeeding decision matrix draws the line row by row.

What breaks

AreaChangeSeverity
Existing databasesRemoving a HasData call generates DeleteData, which deletes the seeded rows on migrationhigh
IdempotencyHasData was diffed automatically; UseSeeding runs every time and needs a hand-written existence checkhigh
Two code pathsTooling calls sync UseSeeding, async startup calls UseAsyncSeeding; implement only one and the other silently no-opshigh
Source controlSeed values leave the migration snapshot and live in startup code; they are no longer captured in the migration historymedium
Referential integrityData other tables’ foreign keys depend on now lands in a startup callback, not inside the migration transactionmedium
Test setupTests that relied on HasData running via EnsureCreated still work, but only if both overloads are implementedlow

The top row is the one that ends up in an incident report. Everything else is mechanical.

Pre-flight checklist

Before you touch a line of seeding code:

Migration steps

The order matters. Doing this in the wrong sequence is exactly how you delete production reference data.

1. Write the UseSeeding and UseAsyncSeeding callbacks first

Add the new seeding path before you remove anything. Both overloads, identical logic, existence check at the top of each. Factor the body into shared methods so the sync and async versions cannot drift:

// Program.cs -- .NET 11, ASP.NET Core 11, EF Core 11 (Microsoft.EntityFrameworkCore 11.0), C# 14
builder.Services.AddDbContext<AppDbContext>(options =>
    options
        .UseSqlServer(builder.Configuration.GetConnectionString("Default"))
        .UseSeeding((context, _) => SeedStatuses(context))
        .UseAsyncSeeding((context, _, ct) => SeedStatusesAsync(context, ct)));

static void SeedStatuses(DbContext context)
{
    string[] required = ["Pending", "Shipped", "Delivered"];
    var existing = context.Set<OrderStatus>()
        .Where(s => required.Contains(s.Name))
        .Select(s => s.Name)
        .ToHashSet();

    var missing = required
        .Where(name => !existing.Contains(name))
        .Select(name => new OrderStatus { Name = name })
        .ToList();

    if (missing.Count > 0)
    {
        context.Set<OrderStatus>().AddRange(missing);
        context.SaveChanges();
    }
}

static async Task SeedStatusesAsync(DbContext context, CancellationToken ct)
{
    string[] required = ["Pending", "Shipped", "Delivered"];
    var existing = await context.Set<OrderStatus>()
        .Where(s => required.Contains(s.Name))
        .Select(s => s.Name)
        .ToListAsync(ct);

    var missing = required
        .Where(name => !existing.Contains(name))
        .Select(name => new OrderStatus { Name = name })
        .ToList();

    if (missing.Count > 0)
    {
        context.Set<OrderStatus>().AddRange(missing);
        await context.SaveChangesAsync(ct);
    }
}

Note that this version no longer hard-codes Id. The database assigns it. That is the whole point: if you were previously assigning keys by hand in HasData, existing foreign-key references may point at those specific values, which is what makes step 2 delicate.

Verify: run the app against an empty local database with the HasData calls still present. It should start cleanly with no duplicate rows. The existence check should make a second startup a no-op (one read query, zero writes).

2. Decide how to preserve existing rows before removing HasData

This is the step everyone skips and regrets. When you delete a HasData call and run dotnet ef migrations add, EF generates a DeleteData in the Up method for every previously-seeded row:

// .NET 11, EF Core 11 -- what removing HasData generates
migrationBuilder.DeleteData(
    table: "OrderStatuses",
    keyColumn: "Id",
    keyValues: new object[] { 1, 2, 3 });

On an existing database, that DELETE runs. If other tables have foreign keys pointing at those rows, the delete either fails with a FOREIGN KEY constraint failed error or, worse, cascades. You have three safe options:

For anything with incoming foreign keys, option B is almost always what you want. The rows do not need to move; only the ownership of them does.

Verify: after generating the removal migration, read the generated file before applying it. Confirm the DeleteData calls target exactly the rows you expect, and that you have chosen and applied A, B, or C for each table.

3. Remove the HasData calls from OnModelCreating

Now delete the HasData block for the seeds you are moving:

// Before -- .NET 11, EF Core 11
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<OrderStatus>().HasData(
        new OrderStatus { Id = 1, Name = "Pending" },
        new OrderStatus { Id = 2, Name = "Shipped" },
        new OrderStatus { Id = 3, Name = "Delivered" });
}

// After -- the HasData block is gone; seeding lives in UseSeeding now
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // OrderStatus seeding moved to UseSeeding in Program.cs
}

Verify: the project still compiles, and dotnet ef migrations has-pending-model-changes reports a pending change (the removed seed) that you are about to capture.

4. Generate and review the removal migration

# .NET 11, EF Core 11
dotnet ef migrations add RemoveOrderStatusSeed

Open the generated migration and apply your decision from step 2. If you chose option B, delete the DeleteData from Up and the corresponding InsertData from Down. Leave the model snapshot changes intact; those are correct and necessary.

Verify: dotnet ef migrations script against your staging database and read the SQL. Confirm no unexpected DELETE FROM OrderStatuses survives if you meant to preserve the rows.

5. Apply against staging and confirm data survives

# .NET 11, EF Core 11
dotnet ef database update

Then start the app so UseAsyncSeeding runs.

Verify: query the table. The reference rows are present exactly once. Start the app a second time and confirm no duplicates appeared, which proves the existence check works. If foreign keys reference the rows, confirm those relationships are intact.

Post-migration smoke test

Run this checklist against staging before you ship:

Rollback plan

This migration is reversible, but read the fine print. If you chose option B (hand-edited the DeleteData out), the schema-level Down is a no-op for the data, so rolling the migration back leaves the rows in place and simply restores the model snapshot. Re-adding the HasData block and generating a new migration returns you to the old world, though EF will want to InsertData any rows that are missing.

If you chose option A (let the delete-and-reseed happen), rollback is riskier: the rows now have database-generated keys, so reverting to a HasData model that expects fixed keys will mismatch. In that case, do not attempt an in-place rollback of live data. Restore from the backup you took in the pre-flight step. This is why the pre-flight backup is not optional.

Gotchas we hit

If your entities are records, none of this changes: records work correctly with EF Core 11 under both HasData and UseSeeding. And if you are tightening the data layer while you are in here, the same “what runs and when” discipline shows up in EF Core 11 interceptors for auditing.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back