How to seed data with UseSeeding and UseAsyncSeeding in EF Core 11
Seed reference data the right way in EF Core 11 with UseSeeding and UseAsyncSeeding: where to configure them, when they run, the idempotency check you cannot skip, and why you must implement both.
To seed data in EF Core 11, configure UseSeeding and UseAsyncSeeding on the DbContextOptionsBuilder, write an existence check at the top of each callback so the insert only runs when the row is missing, and trigger them by calling EnsureCreated/EnsureCreatedAsync, Migrate/MigrateAsync, or dotnet ef database update. The callbacks fire on every one of those operations, even when no migration was applied, so the existence check is what keeps you from inserting duplicates. Implement both the sync and async overloads with the same logic, because EF Core tooling only calls the synchronous one. This post uses .NET 11, EF Core 11 (Microsoft.EntityFrameworkCore 11.0), and C# 14.
UseSeeding and UseAsyncSeeding arrived in EF Core 9 and are the recommended general-purpose seeding mechanism in EF Core 11. They replace the old habit of cramming everything into HasData, which the EF team has since renamed “model managed data” precisely because it was never meant for the dynamic, database-dependent data most apps actually want to seed.
Why UseSeeding exists at all
For years the answer to “how do I put initial rows in my database” was HasData. It works, but it has sharp edges that bite the moment your data is anything other than a fixed lookup table. HasData is baked into the model: EF computes inserts, updates, and deletes by diffing the data in your migration snapshot, so it needs every primary key spelled out by hand, it cannot use database-generated keys, and any value that is not deterministic (a DateTime.UtcNow, a Guid.NewGuid(), a hashed password) makes the model look “changed” on every build. That last one is a common source of the PendingModelChangesWarning that surprises people during a migration from EF Core 6 to EF Core 11.
UseSeeding is plain application code that runs against a live DbContext. You query, you branch, you call external APIs if you need to, you SaveChanges. There is no model snapshot, no key-diffing, no determinism requirement. It is the right tool whenever your seed data is one of: test fixtures, data that depends on what is already in the database, large blobs you do not want captured in migration snapshots, rows whose keys are generated by the database, or anything requiring a transform like password hashing. The official guidance puts it plainly: UseSeeding and UseAsyncSeeding are the recommended way to seed in EF Core, and HasData is now reserved for genuinely static reference data like country codes or ZIP codes.
Where to configure the callbacks
The methods hang off DbContextOptionsBuilder, so they go wherever you build your options. The two common places are OnConfiguring on the context itself, and the AddDbContext registration in Program.cs.
Here is the OnConfiguring form straight from a context class:
// .NET 11, EF Core 11 (Microsoft.EntityFrameworkCore 11.0), C# 14
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder
.UseSqlServer(connectionString)
.UseSeeding((context, _) =>
{
var admin = context.Set<Role>().FirstOrDefault(r => r.Name == "Admin");
if (admin is null)
{
context.Set<Role>().Add(new Role { Name = "Admin" });
context.SaveChanges();
}
})
.UseAsyncSeeding(async (context, _, cancellationToken) =>
{
var admin = await context.Set<Role>()
.FirstOrDefaultAsync(r => r.Name == "Admin", cancellationToken);
if (admin is null)
{
context.Set<Role>().Add(new Role { Name = "Admin" });
await context.SaveChangesAsync(cancellationToken);
}
});
The context handed to the callback is a fully-functional DbContext, so context.Set<T>() gives you the same query and tracking surface you use everywhere else. The discarded _ parameter is a bool that tells you whether EF created the database during this operation; most seeders ignore it.
In a typical ASP.NET Core app you configure the same thing during DI registration. Note the signatures are identical; only the host differs:
// Program.cs -- .NET 11, ASP.NET Core 11, EF Core 11
builder.Services.AddDbContext<AppDbContext>(options =>
options
.UseSqlServer(builder.Configuration.GetConnectionString("Default"))
.UseSeeding((context, _) => SeedRoles(context))
.UseAsyncSeeding(async (context, _, ct) => await SeedRolesAsync(context, ct)));
Pulling the body into named methods (SeedRoles, SeedRolesAsync) keeps the registration readable and gives you one obvious place where all seed logic lives, which was a large part of the point of the feature.
When the callbacks actually run
This is the detail that trips people up, so it is worth stating precisely. The seeding callbacks are invoked as part of:
context.Database.EnsureCreated()callsUseSeeding.context.Database.EnsureCreatedAsync()callsUseAsyncSeeding.context.Database.Migrate()andMigrateAsync()call them.dotnet ef database updatecalls them.
Critically, they run on every invocation of those operations, even when there were no model changes and no migrations were applied. Calling Migrate() on an already-up-to-date database still fires the seed callback. This is by design, and it is the single most important thing to internalise: the framework does not remember that it seeded last time and skip you. Your callback is responsible for deciding whether there is anything to do.
That is why every example above leads with a query. The shape is always the same: look for the row, insert only if it is absent. Skip the check and you will insert “Admin” on every startup that calls Migrate, and within a week you will have a table full of duplicate admin roles.
The idempotency check is not optional
Because the callback re-runs, your seed logic must be idempotent: running it once and running it ten times must leave the database in the same state. The FirstOrDefault/if (x is null) guard in the examples is the minimal form. For a batch of rows, query the set you already have and insert only the difference:
// .NET 11, EF Core 11, C# 14 -- idempotent batch seed
static void SeedRoles(DbContext context)
{
string[] required = ["Admin", "Editor", "Viewer"];
var existing = context.Set<Role>()
.Where(r => required.Contains(r.Name))
.Select(r => r.Name)
.ToHashSet();
var missing = required
.Where(name => !existing.Contains(name))
.Select(name => new Role { Name = name })
.ToList();
if (missing.Count > 0)
{
context.Set<Role>().AddRange(missing);
context.SaveChanges();
}
}
One round-trip to read what exists, one to write only what is new, and nothing happens at all once the table is fully populated. That last property matters: a seeder that issues a SaveChanges on every startup, even a no-op one, is wasteful and noisy in your logs. Compute the delta first, write only when missing.Count > 0.
Do not lean on a unique index plus a swallowed exception as your “idempotency”. That turns every restart after the first into a caught DbUpdateException, which is slow, pollutes logs, and hides real failures. Query first.
Why you must implement both overloads
The note in the docs is easy to skim past and expensive to ignore: EF Core tooling currently relies on the synchronous UseSeeding method, and will not seed correctly if only UseAsyncSeeding is implemented. So when you run dotnet ef database update, it is the sync callback that fires, regardless of how async your application code is.
The reverse also holds. If your application starts up with await context.Database.MigrateAsync() (the idiomatic async startup), that path calls UseAsyncSeeding, not UseSeeding. Implement only the sync one and your app’s own startup seeding silently does nothing while your CLI seeding works, or vice versa.
The safe rule: implement both, with identical logic. Factor the body into a shared method so the two callbacks cannot drift apart:
// .NET 11, EF Core 11, C# 14
options
.UseSeeding((context, _) => SeedRoles(context))
.UseAsyncSeeding((context, _, ct) => SeedRolesAsync(context, ct));
// sync and async bodies kept in lockstep
static void SeedRoles(DbContext context) { /* query, branch, SaveChanges */ }
static async Task SeedRolesAsync(DbContext context, CancellationToken ct)
{
// same query, same branch, SaveChangesAsync(ct)
}
Resist the temptation to implement one by blocking on the other (SeedRolesAsync(context, ct).GetAwaiter().GetResult() inside the sync callback, or Task.Run around the sync body in the async one). Sync-over-async invites deadlocks under some synchronization contexts, and async-over-sync just lies about being asynchronous. Write the two bodies out; they are short.
Concurrency is handled, but only for the seed body
A genuinely nice property: the code inside UseSeeding and UseAsyncSeeding is protected by EF Core’s migration locking mechanism. When two instances of your app start at the same moment and both call Migrate, the lock serialises them, so they do not both rush past the existence check and double-insert. This is a real advantage over hand-rolled startup seeding, where you would have to build that coordination yourself.
The protection covers the seeding callback specifically. It does not turn your whole application into a single-writer system, and it does not protect data you write outside the seed path. Treat it as exactly what it is: a guard that makes the seed step safe to run from many instances concurrently.
When UseSeeding is the wrong choice
UseSeeding is not a hammer for every nail. Two cases push you elsewhere.
First, genuinely static reference data that never changes outside of a schema migration — the canonical example being a table of ZIP codes or ISO country codes — is still better served by HasData. It travels with the migration, it is versioned alongside the schema, and it does not require a runtime query on every startup. Reach for HasData when the data is fixed, deterministic, small, and you are happy for it to be owned by migrations.
Second, seeding that needs two different DbContext instances inside one transaction cannot be expressed cleanly in a single UseSeeding callback, which is handed one context. For that, the docs point you back to plain custom initialization logic: open the contexts yourself, run the work, and crucially keep it out of the normal request path so you do not hit concurrency issues or require the running app to hold schema-modification permissions.
// .NET 11, EF Core 11 -- custom initialization, run once at deploy time
await using var context = new AppDbContext();
await context.Database.MigrateAsync();
if (!await context.Roles.AnyAsync())
{
context.Roles.AddRange(new Role { Name = "Admin" }, new Role { Name = "Viewer" });
await context.SaveChangesAsync();
}
The warning in the docs is worth repeating: seeding generally should not be part of normal app execution. Running it on every instance’s startup means every instance needs write permission and you are relying on the lock for correctness. For production, a dedicated one-shot initialization step at deploy time is cleaner. UseSeeding shines for local development, tests, and the kind of small idempotent reference data where the per-startup query is cheap.
Putting it together
The mental model is short. UseSeeding and UseAsyncSeeding are application code that EF Core calls on EnsureCreated, Migrate, and dotnet ef database update. They run every time, so your first line is always an existence check and your write only happens for missing rows. You implement both overloads because the tooling and your async startup path call different ones. The seed body is lock-protected so concurrent startups do not collide. And HasData is still there for the narrow case of static, deterministic, migration-owned reference data.
If you are tightening up the rest of your EF Core 11 data layer, the same care about what runs and when shows up elsewhere: see how EF Core 11 interceptors handle auditing at the SaveChanges choke point, when to prefer ExecuteUpdate over loading entities and calling SaveChanges for bulk writes, and why AsNoTracking versus AsNoTrackingWithIdentityResolution matters for read-heavy queries. If your seed insert ever trips on the entity type requires a primary key to be defined, that is a modeling issue to fix before the seeder will run.
Sources: the EF Core data seeding documentation on Microsoft Learn covers the UseSeeding/UseAsyncSeeding API, the execution timing, the both-overloads requirement, and the migration-lock guarantee; the DbContextOptionsBuilder.UseSeeding API reference documents the exact signatures.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.