Data Seeding — HasData, Custom Seeds and Migration Seeds

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
Note: 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.
Tip: For environment-specific seed data (admin users, development sample posts), use a startup seeder service rather than HasData. A seeder checks if data exists before inserting — making it idempotent and safe to run on every startup. This pattern also works with Identity’s UserManager which is not accessible in OnModelCreating. Register the seeder as a IHostedService or call it explicitly after MigrateAsync() in the startup sequence.
Warning: Do not use 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.

🧠 Test Yourself

A HasData() call seeds 3 categories. In the next sprint, the team changes one category name and adds a 4th. What does the new migration contain?