Introduction
A fresh database is useless until something fills it. EF Core gives you three different ways to do that, and most projects pick one and force everything through it. That is how you end up with fake data baked into a migration, or a production table that never got its rows.
The fix is to stop treating seed data as one thing. In this post I build a small .NET 10 expense tracker on EF Core 10 and SQL Server that uses all three approaches side by side: HasData for fixed reference data, UseSeeding / UseAsyncSeeding for default data the app needs, and a custom seeder class with Bogus for local fake data. One fresh database fills itself with all three, and running the app twice doesn't duplicate a single row.
🎬 Watch the full video here:
Three kinds of seed data, three tools
Before any code, sort your data into one of these buckets:
-
Fixed reference data - currency codes, country lists. The same in every environment, and it only changes when you decide to change it. Use
HasData. -
Default data the app needs to work, which can grow - the starting categories in an expense tracker. Users add their own next to them, but the defaults must always be there. Use
UseSeedingandUseAsyncSeeding. - Fake data for local development - so you aren't staring at empty screens. Use your own seeder class.
One thing worth knowing: the Microsoft docs now call HasData "model managed data" and recommend UseSeeding as the default way to seed. HasData isn't deprecated, but it's no longer the general answer.
The demo app
The app is a .NET 10 Minimal API with three entities - Currency, Category and Expense - and an AppDbContext registered with the SQL Server provider (Microsoft.EntityFrameworkCore.SqlServer, plus Microsoft.EntityFrameworkCore.Design for the tooling):
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<Currency> Currencies => Set<Currency>();
public DbSet<Category> Categories => Set<Category>();
public DbSet<Expense> Expenses => Set<Expense>();
// ...
}
Currency uses its three-letter Code as the primary key, Category has an Id and a unique Name, and every Expense points to one of each. In development, Program.cs creates the database and applies pending migrations on startup:
if (app.Environment.IsDevelopment())
{
await using var scope = app.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
// ...
}
That's fine locally. For applying migrations in a real deployment, see EF Core migration bundles in CI/CD.
Approach 1: HasData for fixed reference data
Currencies are the textbook case: purely static, and they don't depend on anything you'd have to read from the database first. They go straight into OnModelCreating:
modelBuilder.Entity<Currency>(currency =>
{
currency.HasKey(c => c.Code);
// ...
currency.HasData(
new Currency { Code = "USD", Name = "US Dollar", Symbol = "$" },
new Currency { Code = "EUR", Name = "Euro", Symbol = "€" },
new Currency { Code = "GBP", Name = "British Pound", Symbol = "£" });
});
Notice that every row sets the primary key explicitly. Even when the database would generate keys for you, HasData needs them: the model snapshot has to identify each row to work out later whether it was added, changed or removed.
HasData only does anything through migrations, so the next step is to create one:
dotnet tool install --global dotnet-ef
dotnet ef migrations add SeedCurrencies
The generated migration turns the HasData call into an InsertData call, and Down deletes the same rows again by key:
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "Currencies",
columns: new[] { "Code", "Name", "Symbol" },
values: new object[,]
{
{ "EUR", "Euro", "€" },
{ "GBP", "British Pound", "£" },
{ "USD", "US Dollar", "$" }
});
}
Now say you want to rename "British Pound" to just "Pound". You change the value in OnModelCreating:
new Currency { Code = "GBP", Name = "Pound", Symbol = "£" });
And you need another migration, because everything about HasData goes through migrations:
dotnet ef migrations add UpdatedCurrencies
EF Core compares the model with the snapshot and generates an update for exactly that one value:
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "Currencies",
keyColumn: "Code",
keyValue: "GBP",
column: "Name",
value: "Pound");
}
Down does the reverse and sets the name back to "British Pound". This is where the explicit primary key pays off: keyValue: "GBP" is how EF Core knows which row to update. After MigrateAsync runs, the __EFMigrationsHistory table lists the applied migrations and the Currencies table has the latest values.
That is the trade-off: data changes are versioned and reviewable, but every edit costs a migration. Fine for currencies, painful for anything that changes often.
Approach 2: UseSeeding for default data that can grow
Categories are different. The app should always start with Food, Transport, Housing and Entertainment, but users can add their own through the API. That's exactly what UseSeeding was added for.
The logic lives in a static CategorySeeder with a synchronous and an asynchronous method:
public static class CategorySeeder
{
private static readonly string[] Defaults = ["Food", "Transport", "Housing", "Entertainment"];
public static void Seed(DbContext context)
{
// same logic, synchronous calls
}
public static async Task SeedAsync(DbContext context, CancellationToken cancellationToken)
{
var categories = context.Set<Category>();
var existing = await categories.Select(c => c.Name).ToHashSetAsync(cancellationToken);
categories.AddRange(Defaults
.Where(name => !existing.Contains(name))
.Select(name => new Category { Name = name }));
await context.SaveChangesAsync(cancellationToken);
}
}
A few things to notice:
- The parameter is the base
DbContext, notAppDbContext. That's what the seeding delegates hand you, so the seeder gets the set withcontext.Set<Category>(). - It loads only the existing names into a
HashSetand inserts the defaults that are missing. That makes it safe to run on every startup and every deployment. - Categories added by users are never touched.
Then wire both methods into the DbContext options in Program.cs:
builder.Services.AddDbContext<AppDbContext>(options => options
.UseSqlServer(builder.Configuration.GetConnectionString("Default"))
.UseSeeding((context, _) => CategorySeeder.Seed(context))
.UseAsyncSeeding((context, _, cancellationToken)
=> CategorySeeder.SeedAsync(context, cancellationToken)));
Why you need both
Which delegate runs depends on who triggers the seeding:
-
UseSeedingruns fordotnet ef database update,Migrate(),EnsureCreated()and migration bundles. -
UseAsyncSeedingruns forMigrateAsync()andEnsureCreatedAsync().
Same job, two entry points, so implement both.
In the demo, the first run with MigrateAsync hits SeedAsync with zero existing categories and inserts all four - with no migration created for them. Switching to Migrate() hits the synchronous Seed instead, which finds the four categories already there and adds nothing.
Approach 3: A custom seeder class for fake data
For fake development data, UseSeeding is the wrong tool. Generating realistic data needs more than a DbContext - configuration, logging, maybe other services - and it should only ever run in development. So this one gets its own class, using the Bogus library:
dotnet add package Bogus
The number of expenses comes from configuration through a small options class in the Options folder:
public class SeedOptions
{
public const string SectionName = "Seed";
public int ExpenseCount { get; set; } = 25;
}
With a matching section in appsettings.Development.json:
{
"Seed": {
"ExpenseCount": 25
}
}
And the registration in Program.cs:
builder.Services.Configure<SeedOptions>
(builder.Configuration.GetSection(SeedOptions.SectionName));
builder.Services.AddScoped<DevDataSeeder>();
The seeder itself uses constructor injection, so it can take whatever it needs:
public class DevDataSeeder(
AppDbContext db,
IOptions<SeedOptions> options,
ILogger<DevDataSeeder> logger)
{
public async Task SeedAsync(CancellationToken cancellationToken = default)
{
if (await db.Expenses.AnyAsync(cancellationToken))
{
logger.LogInformation("Expenses already exist, skipping dev seed");
return;
}
var categories = await db.Categories.ToListAsync(cancellationToken);
var currencies = await db.Currencies.ToListAsync(cancellationToken);
var faker = new Faker<Expense>()
.UseSeed(42)
.RuleFor(e => e.Description, f => f.Commerce.ProductName())
.RuleFor(e => e.Amount, f => f.Finance.Amount(5, 200))
.RuleFor(e => e.Category, f => categories[f.IndexFaker % categories.Count])
.RuleFor(e => e.Currency, f => currencies[f.IndexFaker % currencies.Count]);
db.Expenses.AddRange(faker.Generate(options.Value.ExpenseCount));
await db.SaveChangesAsync(cancellationToken);
// ...
}
}
What matters here:
- The guard at the top. If any expenses exist, it does nothing, so a second run adds no rows.
-
UseSeed(42)makes Bogus generate the same data every time, so everyone on the team gets identical rows on their machine. -
Real foreign keys. Categories and currencies are read from the database - the ones the first two approaches just put there - and
IndexFakerspreads expenses across them.
Finally, call it right after migrating, inside the same development-only block:
if (app.Environment.IsDevelopment())
{
await using var scope = app.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
var seeder = scope.ServiceProvider.GetRequiredService<DevDataSeeder>();
await seeder.SeedAsync();
}
On the first run the database gets 25 realistic-looking expenses. On the next run the guard kicks in and nothing is added.
Data seeding pitfalls
-
Dynamic values in
HasData- putDateTime.UtcNoworGuid.NewGuid()in there and the model is different every time it's built.dotnet ef database updatethen fails with a pending model changes error. Use fixed values, or move that data toUseSeeding. -
Wiring only
UseAsyncSeeding- your app is async, so it runs fine. Thendotnet ef database updateapplies the migrations and fails because no synchronous seed delegate was provided, leaving the tables created but empty. Always wire both. -
Deploying with SQL scripts - this one hurts in production. In the output of
dotnet ef migrations script, the currency inserts are there because they live in migrations. The categories aren't, becauseUseSeedingis code, not SQL, and nothing runs it. If you need seeding in your pipeline, use a migration bundle (which does callUseSeeding) or keep that data in migrations. -
Seeding after a downgrade - seeding also runs after
dotnet ef database update 0, and the seeder fails with "Invalid object name" because theCategoriestable is already gone. If you downgrade like this, check that the table exists first. -
Secrets in seed code - seed code goes into source control and
HasDatavalues end up in your migrations too. If you need an admin account, read the password from configuration, never from a string in code.
Key Takeaways
- Seed data isn't one thing: fixed reference data, default data that can grow and fake development data each need a different tool.
-
HasDatafits fixed reference data that's identical everywhere - set primary keys explicitly and accept one migration per change. -
UseSeedingandUseAsyncSeedingfit default data the app needs; wire both and make the seeder idempotent so it's safe on every deployment. - Fake data or anything that needs real services belongs in your own seeder class, kept behind a development check.
- If you're not sure, start with
UseSeeding- it's what Microsoft recommends and the right default.
Get the Full Source Code
The complete solution - the .NET 10 expense tracker API with the AppDbContext, the HasData currency migrations, the CategorySeeder wired through UseSeeding and UseAsyncSeeding, and the Bogus-based DevDataSeeder with its options - is available to Patreon supporters on Patreon.
Top comments (0)