Entity Framework Core - Data Seeding
For EF Core, there isn't one "correct" way to seed data; the best strategy depends entirely on what kind of data we are seeding (static lookups vs. transactional test data).
Below is a breakdown of the three primary strategies, when to use them, and the industry-standard patterns for production applications.
Strategy 1: Model-Managed Seeding (HasData)
This is the native EF Core method. We define the data in our OnModelCreating method.1 EF Core tracks this data in our Migrations, meaning it will generate INSERT, UPDATE, or DELETE SQL scripts automatically when we run dotnet ef database update.
Best For:
Static Lookup Tables: Countries, Currency Codes, Status Types (e.g., "Pending", "Approved").2
Data that must exist for the application to function and rarely changes.
How it works:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<OrderStatus>().HasData(
new OrderStatus { Id = 1, Name = "Pending" },
new OrderStatus { Id = 2, Name = "Shipped" }
);
}
Pros:
Version Controlled: Changes to data are tracked in migration files.3
Automatic: Applied automatically when updating the database.4
Cons:
Migration Bloat: If we seed 1,000 rows, our migration file becomes massive and slow.
Strict PK Requirement: We must manually specify the Primary Key (ID).5
No Logic: We cannot use services (like
UserManagerfor identity) or dynamic logic (likeDateTime.Now).6
Strategy 2: Custom Initialization Logic (The "Startup Pattern")
Industry Standard for Enterprise Apps
Instead of using EF's internal configuration, we create a dedicated "Seeder" service. we manually invoke this service during the application startup (in Program.cs) immediately after the database migration runs.
Best For:
Identity Data: Creating Admin users and Roles (requires
UserManagerto hash passwords securely).7Dynamic Data: Data needing
DateTime.Nowor logic.8Large Datasets: Inserting 10,000+ rows (performance is better than
HasData).
How it works:
Create a Seeder Interface/Service:
public interface IDbSeeder { Task SeedAsync(); } public class DbSeeder : IDbSeeder { private readonly AppDbContext _context; private readonly UserManager<AppUser> _userManager; public DbSeeder(AppDbContext context, UserManager<AppUser> userManager) { _context = context; _userManager = userManager; } public async Task SeedAsync() { // 1. Check if data exists (Idempotency) if (await _context.Roles.AnyAsync()) return; // 2. Add Data safely await _userManager.CreateAsync(new AppUser { UserName = "admin" }, "Pa$$w0rd"); } }Call it in
Program.cs:var app = builder.Build(); // Create a scope to resolve scoped services using (var scope = app.Services.CreateScope()) { var seeder = scope.ServiceProvider.GetRequiredService<IDbSeeder>(); await seeder.SeedAsync(); } app.Run();
Pros:
Flexible: Can use Dependency Injection (DI) to access other services.
Clean: Keeps our
DbContextclean of data concerns.Secure: Best way to handle password hashing for Identity.
Strategy 3: EF Core 9+ UseSeeding (The New Standard)
If we are on .NET 9 or newer, EF Core introduced a dedicated hook for seeding that combines the ease of Strategy 1 with the power of Strategy 2.
Best For:
- Modern .NET 9+ applications wanting a standardized place for seeding without modifying
Program.cs.
How it works:
We configure this directly in our DbContext setup options.9
options.UseSeeding((context, _) =>
{
var testData = context.Set<Product>().FirstOrDefault(p => p.Name == "Test Product");
if (testData == null)
{
context.Set<Product>().Add(new Product { Name = "Test Product", Price = 10 });
context.SaveChanges();
}
});
options.UseAsyncSeeding(async (context, _, cancellationToken) =>
{
// Async logic here
});
Summary of Use Cases
| Scenario | Recommended Strategy | Why? |
| Lookup Tables (Status, Country) | Strategy 1 (HasData) | Keeps static data consistent across all environments via migrations. |
| Users / Roles / Identity | Strategy 2 (Seeder Service) | Requires UserManager for password hashing; HasData cannot do this securely. |
| Dev / Test Dummy Data | Strategy 2 (Seeder Service) | Use libraries like Bogus to generate fake data only when Environment.IsDevelopment(). |
| Massive Data (ZipCodes) | SQL Script / Bulk Insert | EF Core change tracking is too slow for millions of rows. Use raw SQL or SqlBulkCopy. |
Industry Standard Practices
1. Idempotency is King
Our seed logic must be runnable multiple times without crashing or creating duplicates.
Bad:
context.Add(new Role("Admin"));(Crashes on 2nd run)Good:
if (!context.Roles.Any(r => r.Name == "Admin")) { ... }
2. Environment Separation
Do not seed fake data in Production.
public async Task SeedAsync()
{
// Always seed these (Lookups)
await SeedLookupsAsync();
// Only seed these in Dev (Fake Users)
if (_environment.IsDevelopment())
{
await SeedFakeDataAsync();
}
}
3. Use "Bogus" for Dummy Data
Don't write manual loops to create fake users. Use the Bogus library.
var faker = new Faker<User>()
.RuleFor(u => u.FirstName, f => f.Name.FirstName())
.RuleFor(u => u.Email, f => f.Internet.Email());
var users = faker.Generate(100); // Generates 100 realistic fake users
4. Extension Methods for Cleanup
If we use HasData, do not put 500 lines of code inside OnModelCreating. Move it to an extension method.
// In OnModelCreating
modelBuilder.SeedCountries(); // Much cleaner
5. Disable Change Tracking for Bulk Seeds
If seeding large data via a custom seeder, performance will tank if EF Core tracks every entity.
// Optimizes performance for large inserts
_context.ChangeTracker.AutoDetectChangesEnabled = false;
await _context.Products.AddRangeAsync(largeList);
await _context.SaveChangesAsync();
_context.ChangeTracker.AutoDetectChangesEnabled = true;

