Seed data falls into two categories: static reference data that is part of the schema (roles, categories, configuration) — seeded via HasData() in migrations; and dynamic initial data that depends on the environment (admin users, sample posts for development) — seeded via code at application startup. Understanding which kind of data belongs where prevents the common mistake of hardcoding development sample data into production migrations.
HasData — Migration-Controlled Seeds
// ── HasData in entity configuration — part of migrations ─────────────────
public class CategoryConfiguration : IEntityTypeConfiguration<Category>
{
public void Configure(EntityTypeBuilder<Category> entity)
{
entity.HasKey(c => c.Id);
entity.Property(c => c.Name).IsRequired().HasMaxLength(100);
entity.Property(c => c.Slug).IsRequired().HasMaxLength(100);
// Static seed data — becomes part of a migration
// IDs must be hardcoded (not auto-generated) for HasData
entity.HasData(
new Category { Id = 1, Name = "Technology", Slug = "technology" },
new Category { Id = 2, Name = "Programming", Slug = "programming" },
new Category { Id = 3, Name = "Web Dev", Slug = "web-dev" });
}
}
// Running dotnet ef migrations add SeedCategories creates a migration with
// InsertData() calls for these rows — applied once and tracked in history
HasData() requires hardcoded IDs because EF Core needs a stable identifier to track whether the seed row has already been inserted, updated, or should be deleted. If a HasData record changes between migrations, EF Core generates an UpdateData() migration. If it is removed from HasData, EF Core generates a DeleteData() migration. This makes HasData suitable only for truly static reference data — data that will not change in nature after initial seeding, only potentially be updated by a future migration.UserManager which is not accessible in OnModelCreating. Register the seeder as a IHostedService or call it explicitly after MigrateAsync() in the startup sequence.HasData() for data that is environment-specific (admin user passwords, development sample content, test data). HasData data is applied to ALL environments via migrations — including production. A development admin user seeded via HasData appears in the production database. Keep production seeds to truly universal reference data. Use conditional seeding with environment checks for environment-specific data: if (app.Environment.IsDevelopment()) await seeder.SeedDevelopmentDataAsync();Startup Seeder Service
// ── IDataSeeder interface ─────────────────────────────────────────────────
public interface IDataSeeder
{
Task SeedAsync(CancellationToken ct = default);
}
// ── Role and admin user seeder ────────────────────────────────────────────
public class RoleSeeder(
RoleManager<IdentityRole> roleManager,
UserManager<ApplicationUser> userManager,
IConfiguration config,
ILogger<RoleSeeder> logger) : IDataSeeder
{
private static readonly string[] Roles = ["Admin", "Editor", "Subscriber"];
public async Task SeedAsync(CancellationToken ct = default)
{
// Idempotent — safe to call multiple times
foreach (var role in Roles)
{
if (!await roleManager.RoleExistsAsync(role))
{
await roleManager.CreateAsync(new IdentityRole(role));
logger.LogInformation("Created role: {Role}", role);
}
}
// Seed admin user (from config/secrets — never hardcoded)
var adminEmail = config["Seed:AdminEmail"]!;
var adminPassword = config["Seed:AdminPassword"]!;
if (await userManager.FindByEmailAsync(adminEmail) is null)
{
var admin = new ApplicationUser { UserName = adminEmail, Email = adminEmail };
var result = await userManager.CreateAsync(admin, adminPassword);
if (result.Succeeded)
{
await userManager.AddToRoleAsync(admin, "Admin");
logger.LogInformation("Admin user created: {Email}", adminEmail);
}
}
}
}
// ── Run seeders at startup after MigrateAsync ─────────────────────────────
using (var scope = app.Services.CreateScope())
{
await scope.ServiceProvider
.GetRequiredService<AppDbContext>()
.Database.MigrateAsync();
await scope.ServiceProvider
.GetRequiredService<RoleSeeder>()
.SeedAsync();
}
// Register
builder.Services.AddScoped<RoleSeeder>();
Common Mistakes
Mistake 1 — Using HasData for environment-specific data (appears in production)
❌ Wrong — dev admin user and sample posts in HasData; production database seeded with test data.
✅ Correct — HasData for universal reference data; startup seeders for environment-specific data.
Mistake 2 — Non-idempotent seeders (duplicates on every restart)
❌ Wrong — seeder inserts without checking existence; every app restart duplicates seed data.
✅ Correct — always check with AnyAsync() or FindByEmailAsync() before inserting; seeders must be safe to run multiple times.