Overview
Data seeding is the process of putting initial or required data into a database. In Entity Framework Core, seeding is commonly used for lookup values, default configuration records, roles, permissions, demo data, test data, feature flags, country lists, product categories, and initial admin users.
This topic focuses on choosing the right EF Core seeding strategy, especially the difference between:
HasData, also known as model-managed data.UseSeedingandUseAsyncSeeding, introduced as a general-purpose EF Core seeding option.- Custom runtime seed logic.
- Manual migration customization.
- Test and development seed data.
This topic matters because seeding looks simple at first, but the wrong strategy can create production problems. A bad seed design can cause duplicate records, broken migrations, inconsistent environments, large migration files, accidental overwrites, concurrency issues during deployment, or insecure default users.
In real applications, data seeding is used for:
- Static lookup data such as countries, currencies, statuses, and categories.
- Required application roles and permissions.
- Default tenant configuration.
- Development sample data.
- Integration test setup.
- Demo environments.
- Initial admin accounts.
- Seed data needed for feature startup.
- Reference data required by business rules.
This topic is important for interviews because it tests whether a developer understands EF Core beyond basic CRUD. Interviewers often ask:
- What is
HasDataused for? - Why is
HasDatanot ideal for all seeding? - What are
UseSeedingandUseAsyncSeeding? - What is the difference between model-managed data and runtime seeding?
- How do migrations interact with seeded data?
- Why must
HasDataspecify primary keys? - How should roles and admin users be seeded?
- Why is seeding during normal app startup risky?
- How should seeding work in production?
- How do you make seed logic idempotent?
- How do you avoid duplicate seed data?
A strong answer should not say "always use HasData" or "always seed on startup." The correct choice depends on the type of data, whether it is static or dynamic, whether it depends on current database state, whether database-generated keys are needed, whether external services are involved, and how the deployment process applies migrations.
Core Concepts
What Data Seeding Means
Data seeding means adding initial data to a database.
Example seed data:
Countries:
- USA
- Canada
- Mexico
Roles:
- Admin
- Manager
- User
Order statuses:
- Draft
- Submitted
- Paid
- Cancelled
A database schema defines the structure. Seed data fills that structure with required initial values.
For example, an application may not work unless these roles exist:
Admin
User
Support
Or an order workflow may require these statuses:
Pending
Approved
Rejected
Completed
In EF Core, seed data can be added in multiple ways:
UseSeedingandUseAsyncSeeding.HasData.- Custom initialization code.
- Manual migration operations.
- SQL scripts.
- Separate database initialization tools.
- Test setup code.
Each option has different behavior and trade-offs.
Main EF Core Seeding Options
EF Core supports several practical seeding choices.
The best strategy depends on the type of data and lifecycle.
UseSeeding and UseAsyncSeeding
UseSeeding and UseAsyncSeeding are EF Core configuration methods for general-purpose data seeding.
They are configured on DbContextOptionsBuilder.
Example:
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(connectionString)
.UseSeeding((context, _) =>
{
var hasAdminRole = context.Set<Role>()
.Any(r => r.Name == "Admin");
if (!hasAdminRole)
{
context.Set<Role>().Add(new Role
{
Name = "Admin"
});
context.SaveChanges();
}
})
.UseAsyncSeeding(async (context, _, cancellationToken) =>
{
var hasAdminRole = await context.Set<Role>()
.AnyAsync(r => r.Name == "Admin", cancellationToken);
if (!hasAdminRole)
{
context.Set<Role>().Add(new Role
{
Name = "Admin"
});
await context.SaveChangesAsync(cancellationToken);
}
});
});
A simple entity:
public sealed class Role
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
}
This approach is useful because seed logic is normal C# code. It can query the database, check whether data already exists, and insert missing records.
Important interview point: implement both synchronous and asynchronous versions when using this feature, because tooling may rely on the synchronous path.
Why UseSeeding Is Useful
UseSeeding and UseAsyncSeeding are useful because they provide a clear place for initialization logic.
Benefits:
- Central location for seed logic.
- Can query current database state.
- Can be idempotent.
- Can use database-generated keys.
- Can insert data conditionally.
- Can use normal EF Core operations.
- Can run when migrations are applied.
- Is protected by EF Core's migration locking mechanism when used with migration operations.
Example idempotent seed:
options.UseAsyncSeeding(async (context, _, cancellationToken) =>
{
var requiredStatuses = new[]
{
"Draft",
"Submitted",
"Paid",
"Cancelled"
};
foreach (var statusName in requiredStatuses)
{
var exists = await context.Set<OrderStatus>()
.AnyAsync(s => s.Name == statusName, cancellationToken);
if (!exists)
{
context.Set<OrderStatus>().Add(new OrderStatus
{
Name = statusName
});
}
}
await context.SaveChangesAsync(cancellationToken);
});
This avoids hard-coding primary key values and avoids duplicate inserts if the seed code runs more than once.
HasData
HasData configures data as part of the EF Core model.
Example:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<OrderStatus>().HasData(
new OrderStatus { Id = 1, Name = "Draft" },
new OrderStatus { Id = 2, Name = "Submitted" },
new OrderStatus { Id = 3, Name = "Paid" },
new OrderStatus { Id = 4, Name = "Cancelled" });
}
Entity:
public sealed class OrderStatus
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
}
When a migration is created, EF Core compares the configured data with the model snapshot and generates migration operations such as:
migrationBuilder.InsertData(
table: "OrderStatuses",
columns: new[] { "Id", "Name" },
values: new object[,]
{
{ 1, "Draft" },
{ 2, "Submitted" },
{ 3, "Paid" },
{ 4, "Cancelled" }
});
This is why HasData is sometimes described as model-managed data. EF Core migrations manage it based on the model snapshot.
HasData Is Model-Managed Data
The important mental model is this:
HasData is not general runtime seeding. It is model-managed data.
This means:
- Data is stored in the EF Core model snapshot.
- Migrations compare old seed data and new seed data.
- Migrations generate
InsertData,UpdateData, andDeleteData. - EF Core does not query the database to decide what changed.
- The primary key must be specified.
- Data changed outside migrations can conflict with generated migration operations.
- It works best for static deterministic reference data.
Good HasData examples:
Country codes
Currency codes
Fixed order statuses
Static lookup categories
System permission definitions
Poor HasData examples:
Temporary test data
Random sample data
Users with hashed passwords
Data that depends on existing rows
Data that calls external APIs
Large product catalogs
Environment-specific records
Data using DateTime.Now
Data needing database-generated keys
Why HasData Requires Primary Keys
HasData requires primary key values because migrations need a stable way to identify each row across migration snapshots.
Example:
modelBuilder.Entity<Country>().HasData(
new Country { Id = 1, Code = "US", Name = "United States" },
new Country { Id = 2, Code = "CA", Name = "Canada" });
If you change a primary key value, EF Core treats it as a different row. A migration may delete the old seed row and insert a new one.
Bad change:
// Old
new Country { Id = 1, Code = "US", Name = "United States" }
// New
new Country { Id = 100, Code = "US", Name = "United States" }
This can produce delete and insert operations instead of a simple update.
Best practice:
- Use stable seed keys.
- Do not change seed primary keys casually.
- Avoid
HasDatafor data where database-generated keys are required. - Consider natural keys carefully, but remember EF still needs configured key values.
HasData with Relationships
When seeding related data with HasData, foreign key values must be specified.
Example:
public sealed class Country
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public List<City> Cities { get; } = new();
}
public sealed class City
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public int CountryId { get; set; }
public Country Country { get; set; } = null!;
}
Seed data:
modelBuilder.Entity<Country>().HasData(
new Country { Id = 1, Name = "USA" },
new Country { Id = 2, Name = "Canada" });
modelBuilder.Entity<City>().HasData(
new City { Id = 1, Name = "Seattle", CountryId = 1 },
new City { Id = 2, Name = "Vancouver", CountryId = 2 });
Do not try to seed by assigning navigation properties:
// Not the right style for HasData
new City
{
Id = 1,
Name = "Seattle",
Country = new Country { Id = 1, Name = "USA" }
}
For HasData, use scalar key and foreign key values.
HasData with Many-to-Many Relationships
For many-to-many relationships, seed the join table explicitly.
Example model:
public sealed class User
{
public int Id { get; set; }
public string UserName { get; set; } = string.Empty;
public List<Role> Roles { get; } = new();
}
public sealed class Role
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public List<User> Users { get; } = new();
}
Configuration:
modelBuilder.Entity<User>().HasData(
new User { Id = 1, UserName = "admin" });
modelBuilder.Entity<Role>().HasData(
new Role { Id = 1, Name = "Admin" });
modelBuilder.Entity<User>()
.HasMany(u => u.Roles)
.WithMany(r => r.Users)
.UsingEntity<Dictionary<string, object>>(
"UserRole",
join => join.HasOne<Role>().WithMany().HasForeignKey("RoleId"),
join => join.HasOne<User>().WithMany().HasForeignKey("UserId"),
join =>
{
join.HasKey("UserId", "RoleId");
join.HasData(new
{
UserId = 1,
RoleId = 1
});
});
In production, user seeding is often better handled with runtime seed logic because password hashing, user creation, and identity-related workflows are usually not good fits for HasData.
HasData with Owned Entity Types
Owned types can be seeded, but the owner key must be provided.
Example:
public sealed class Language
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public LanguageDetails Details { get; set; } = new();
}
public sealed class LanguageDetails
{
public bool IsPhonetic { get; set; }
public int PhonemeCount { get; set; }
}
Configuration:
modelBuilder.Entity<Language>().HasData(
new Language { Id = 1, Name = "English" });
modelBuilder.Entity<Language>()
.OwnsOne(l => l.Details)
.HasData(new
{
LanguageId = 1,
IsPhonetic = false,
PhonemeCount = 44
});
Owned type seeding often uses anonymous objects because shadow key or owner key values may not exist as CLR properties on the owned type.
Limitations of HasData
HasData has important limitations.
It is not a good fit when:
- Data depends on existing database state.
- Data is large.
- Data is temporary test data.
- Data needs generated keys.
- Data uses random values.
- Data uses
DateTime.Now. - Data requires password hashing.
- Data requires external API calls.
- Data changes outside migrations.
- Data differs per environment.
- Data should not live in migration snapshots.
- Data needs custom business logic to create.
Example problem:
modelBuilder.Entity<User>().HasData(
new User
{
Id = 1,
Email = "[email protected]",
PasswordHash = HashPassword("P@ssw0rd!")
});
This is usually a poor design because password hashing is runtime logic, may require identity services, may change algorithm versions, and may involve sensitive default credentials.
Better approach:
public sealed class IdentitySeedService
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly RoleManager<IdentityRole> _roleManager;
public IdentitySeedService(
UserManager<ApplicationUser> userManager,
RoleManager<IdentityRole> roleManager)
{
_userManager = userManager;
_roleManager = roleManager;
}
public async Task SeedAsync(CancellationToken cancellationToken)
{
if (!await _roleManager.RoleExistsAsync("Admin"))
{
await _roleManager.CreateAsync(new IdentityRole("Admin"));
}
var admin = await _userManager.FindByEmailAsync("[email protected]");
if (admin is null)
{
admin = new ApplicationUser
{
UserName = "[email protected]",
Email = "[email protected]"
};
await _userManager.CreateAsync(admin, "ChangeThisPassword!123");
await _userManager.AddToRoleAsync(admin, "Admin");
}
}
}
This can use the proper Identity APIs and can be made environment-specific and secure.
Runtime Seed Logic
Runtime seed logic means using normal C# code to query and insert/update data.
Example:
public static class DatabaseSeeder
{
public static async Task SeedAsync(
AppDbContext context,
CancellationToken cancellationToken = default)
{
var statuses = new[]
{
"Draft",
"Submitted",
"Paid",
"Cancelled"
};
foreach (var status in statuses)
{
var exists = await context.OrderStatuses
.AnyAsync(s => s.Name == status, cancellationToken);
if (!exists)
{
context.OrderStatuses.Add(new OrderStatus
{
Name = status
});
}
}
await context.SaveChangesAsync(cancellationToken);
}
}
Usage:
using var scope = app.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await DatabaseSeeder.SeedAsync(context);
Runtime logic is flexible, but it must be used carefully. Running it automatically in every application instance during normal startup can create concurrency and permission issues, especially in production.
Idempotent Seed Logic
Seed logic should usually be idempotent.
Idempotent means it can run multiple times and produce the same final result without creating duplicates or corrupting data.
Bad seed logic:
context.Roles.Add(new Role { Name = "Admin" });
await context.SaveChangesAsync();
If this runs twice, it may insert duplicate roles or fail on a unique constraint.
Better seed logic:
var exists = await context.Roles
.AnyAsync(r => r.Name == "Admin", cancellationToken);
if (!exists)
{
context.Roles.Add(new Role { Name = "Admin" });
await context.SaveChangesAsync(cancellationToken);
}
Even better: enforce uniqueness at the database level.
modelBuilder.Entity<Role>()
.HasIndex(r => r.Name)
.IsUnique();
Idempotency should not rely only on application checks. A unique index protects the database if two processes try to seed the same row at the same time.
Runtime Seeding and Concurrency
Runtime seed logic can have concurrency problems if multiple app instances run it at the same time.
Example:
Instance A checks if Admin role exists -> false
Instance B checks if Admin role exists -> false
Instance A inserts Admin
Instance B inserts Admin
Possible results:
- Duplicate rows.
- Unique constraint violation.
- Startup failure.
- Partial seed state.
Mitigation strategies:
- Use EF Core
UseSeeding/UseAsyncSeedingwith migration locking. - Run seed logic in a separate deployment/init job.
- Use database uniqueness constraints.
- Use transactions where appropriate.
- Use database locks or application locks when needed.
- Ensure only one instance performs seeding.
- Make seed logic retry-safe.
- Do not run high-risk seed logic in normal app startup in production.
Migration Locking
Modern EF Core includes migration locking around migration operations. This protects migration and seeding operations from concurrent execution when multiple processes attempt to migrate at the same time.
This matters because UseSeeding and UseAsyncSeeding are called as part of migration-related operations and are protected by the migration lock.
Practical impact:
- Reduces risk of two app instances applying migrations at the same time.
- Helps protect seed logic that runs with migrations.
- Does not mean every startup migration pattern is automatically a good production strategy.
- Provider behavior can vary.
- SQL scripts applied outside EF Core are not protected by EF Core's migration lock.
Interview nuance: migration locking improves safety, but production deployments still need careful database migration strategy, least-privilege permissions, reviewable scripts or migration bundles, and rollback planning.
EnsureCreated vs Migrate
EnsureCreated and Migrate are different.
EnsureCreated creates the database schema directly from the current model if the database does not exist. It bypasses migrations.
await context.Database.EnsureCreatedAsync();
Migrate applies pending migrations.
await context.Database.MigrateAsync();
Important rule:
Do not use EnsureCreated and migrations together for the same relational database lifecycle.
Bad:
await context.Database.EnsureCreatedAsync();
await context.Database.MigrateAsync();
EnsureCreated is useful for:
- Simple prototypes.
- Tests.
- In-memory or non-relational scenarios.
- Throwaway databases.
Migrate is used when:
- You manage schema with EF Core migrations.
- You need incremental schema evolution.
- You need production database changes over time.
For seeding:
EnsureCreatedcan create a new database and include model-managed data.Migrateapplies migrations and can run configured seeding logic.- If the database already exists,
EnsureCreateddoes not update schema or seed data.
Applying Migrations and Seeding in Production
Production database changes should be controlled.
Common safer strategies:
- Generate SQL migration scripts and review them.
- Use idempotent migration scripts when target migration state differs across environments.
- Use migration bundles.
- Run migrations and seeds as part of a deployment job.
- Use a separate initialization executable.
- Coordinate with a DBA when needed.
- Apply least-privilege permissions to the main application.
- Avoid letting every app instance modify schema on startup.
Applying migrations at runtime during app startup is convenient for development, but it can be risky in production.
Risks:
- Multiple instances may migrate or seed at the same time.
- The app needs elevated schema permissions.
- SQL is applied without manual review.
- Startup can fail if migration fails.
- Rollback strategy may be unclear.
- The app may run while the schema is changing.
- Long migrations can delay startup or cause downtime.
A strong interview answer should distinguish development convenience from production deployment safety.
Manual Migration Customization
Sometimes seed data should be tied to a specific migration.
Example:
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "OrderStatuses",
columns: new[] { "Id", "Name" },
values: new object[,]
{
{ 1, "Draft" },
{ 2, "Submitted" },
{ 3, "Paid" }
});
}
Manual migration customization is useful when:
- Data change is tied to a schema change.
- A one-time data correction is needed.
- Data must be transformed during migration.
- A column is split or renamed and values must be copied.
- A backfill is required.
Example data backfill:
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "NormalizedEmail",
table: "Users",
nullable: true);
migrationBuilder.Sql("""
UPDATE Users
SET NormalizedEmail = UPPER(Email)
WHERE Email IS NOT NULL
""");
}
This is not general seeding. It is a migration-specific data operation.
Seeding Lookup Data
Lookup data is one of the best use cases for HasData when it is fixed and deterministic.
Example:
public sealed class OrderStatus
{
public int Id { get; set; }
public string Code { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
}
Configuration:
modelBuilder.Entity<OrderStatus>(builder =>
{
builder.HasIndex(s => s.Code).IsUnique();
builder.HasData(
new OrderStatus { Id = 1, Code = "DRAFT", DisplayName = "Draft" },
new OrderStatus { Id = 2, Code = "SUBMITTED", DisplayName = "Submitted" },
new OrderStatus { Id = 3, Code = "PAID", DisplayName = "Paid" },
new OrderStatus { Id = 4, Code = "CANCELLED", DisplayName = "Cancelled" });
});
This works well because:
- Values are fixed.
- Keys are stable.
- Data is small.
- Data changes only through migrations.
- It does not depend on current database state.
However, if admins can edit statuses in production, HasData becomes risky because migrations may overwrite or conflict with production changes.
Seeding Roles and Permissions
Roles and permissions can be seeded in different ways depending on the application design.
Static permissions are often good candidates for HasData:
modelBuilder.Entity<Permission>().HasData(
new Permission { Id = 1, Code = "orders.read", Description = "Read orders" },
new Permission { Id = 2, Code = "orders.write", Description = "Create and update orders" },
new Permission { Id = 3, Code = "orders.approve", Description = "Approve orders" });
Role-permission mappings can be seeded if they are fixed:
modelBuilder.Entity<RolePermission>().HasData(
new RolePermission { RoleId = 1, PermissionId = 1 },
new RolePermission { RoleId = 1, PermissionId = 2 },
new RolePermission { RoleId = 1, PermissionId = 3 });
ASP.NET Core Identity roles and users are often better seeded with runtime logic because Identity APIs handle normalization, password hashing, validators, security stamps, and other details.
Example runtime role seeding:
public static async Task SeedRolesAsync(RoleManager<IdentityRole> roleManager)
{
var roles = new[] { "Admin", "User", "Support" };
foreach (var roleName in roles)
{
if (!await roleManager.RoleExistsAsync(roleName))
{
await roleManager.CreateAsync(new IdentityRole(roleName));
}
}
}
Seeding Admin Users
Initial admin user seeding must be handled carefully.
Bad practice:
modelBuilder.Entity<User>().HasData(
new User
{
Id = 1,
Email = "[email protected]",
PasswordHash = "hard-coded-hash"
});
Problems:
- Hard-coded credentials.
- Password hash may depend on Identity configuration.
- Security stamp and normalization may be wrong.
- Password may leak through source control.
- Different environments need different admin users.
- Rotating or disabling the initial password is harder.
Better approach:
- Use runtime seed logic.
- Read initial admin email from secure configuration.
- Read temporary password from a secret store.
- Force password change on first login.
- Disable seeding after initial setup.
- Make the operation idempotent.
- Use Identity APIs.
- Avoid logging secrets.
Example:
public sealed class AdminUserSeeder
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly RoleManager<IdentityRole> _roleManager;
private readonly IConfiguration _configuration;
public AdminUserSeeder(
UserManager<ApplicationUser> userManager,
RoleManager<IdentityRole> roleManager,
IConfiguration configuration)
{
_userManager = userManager;
_roleManager = roleManager;
_configuration = configuration;
}
public async Task SeedAsync()
{
var email = _configuration["SeedAdmin:Email"];
var password = _configuration["SeedAdmin:Password"];
if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(password))
{
return;
}
if (!await _roleManager.RoleExistsAsync("Admin"))
{
await _roleManager.CreateAsync(new IdentityRole("Admin"));
}
var user = await _userManager.FindByEmailAsync(email);
if (user is null)
{
user = new ApplicationUser
{
UserName = email,
Email = email,
EmailConfirmed = true
};
var createResult = await _userManager.CreateAsync(user, password);
if (createResult.Succeeded)
{
await _userManager.AddToRoleAsync(user, "Admin");
}
}
}
}
Environment-Specific Seeding
Different environments may need different seed data.
Examples:
Environment-specific seeding should be explicit.
Example:
if (app.Environment.IsDevelopment())
{
await DevelopmentSeeder.SeedAsync(app.Services);
}
Avoid accidentally seeding development data into production.
Bad:
await DemoDataSeeder.SeedAsync(app.Services);
Better:
if (app.Environment.IsDevelopment())
{
await DemoDataSeeder.SeedAsync(app.Services);
}
For production, seed only required operational data, not fake sample data.
Test Data Seeding
Test data seeding is different from production seeding.
Integration tests often need deterministic data.
Example:
public static async Task SeedTestDataAsync(AppDbContext context)
{
context.Customers.Add(new Customer
{
Name = "Test Customer"
});
context.Orders.Add(new Order
{
OrderNumber = "TEST-001",
Total = 100
});
await context.SaveChangesAsync();
}
Best practices for tests:
- Use deterministic data.
- Reset database state between tests when needed.
- Avoid relying on production seed logic for all tests.
- Use builders or factories for test entities.
- Keep test seed data small and focused.
- Use the real relational provider when testing EF behavior.
- Avoid EF InMemory provider for relational behavior tests.
Test seed data should not pollute production migrations.
Large Seed Data
Large seed data is usually not a good fit for HasData.
Problems:
- Large migration snapshots.
- Large migration files.
- Slow migrations.
- Difficult code reviews.
- Source control noise.
- Potential memory and tooling overhead.
- Harder data updates.
Better options for large data:
- Bulk import scripts.
- CSV import tools.
- Separate data migration process.
- SQL scripts reviewed by DBAs.
- Application-specific import job.
- ETL pipeline.
- Runtime seed job with batching.
- Database backup/restore for demo data.
Example runtime batching:
foreach (var batch in products.Chunk(500))
{
context.Products.AddRange(batch);
await context.SaveChangesAsync(cancellationToken);
context.ChangeTracker.Clear();
}
Large seed data should be handled as a data import problem, not a model snapshot problem.
Deterministic vs Non-Deterministic Seed Data
HasData should use deterministic values.
Good:
new OrderStatus { Id = 1, Code = "DRAFT", DisplayName = "Draft" }
Bad:
new OrderStatus
{
Id = 1,
Code = "DRAFT",
DisplayName = "Draft",
CreatedAtUtc = DateTime.UtcNow
}
DateTime.UtcNow, Guid.NewGuid(), random values, and environment-specific values are not good fits for model-managed data because migrations compare snapshots and expect stable values.
If you need runtime-generated values, use runtime seed logic.
Example:
if (!await context.ApiClients.AnyAsync(c => c.Name == "DefaultClient"))
{
context.ApiClients.Add(new ApiClient
{
Name = "DefaultClient",
ClientSecret = secretGenerator.Generate(),
CreatedAtUtc = timeProvider.GetUtcNow().UtcDateTime
});
await context.SaveChangesAsync();
}
Seed Data and Database Constraints
Seed logic should work with database constraints, not replace them.
Example unique constraint:
modelBuilder.Entity<Role>()
.HasIndex(r => r.Name)
.IsUnique();
Seed logic:
if (!await context.Roles.AnyAsync(r => r.Name == "Admin"))
{
context.Roles.Add(new Role { Name = "Admin" });
await context.SaveChangesAsync();
}
The application check avoids normal duplicate inserts. The unique constraint protects against race conditions and manual database changes.
Best practice:
- Add unique indexes for natural unique values.
- Use transactions when multiple seed records must be consistent.
- Handle unique constraint failures gracefully if concurrent seeding is possible.
- Do not rely only on "check then insert" logic.
Seed Data and Transactions
Some seed operations must be atomic.
Example:
await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken);
try
{
var role = await context.Roles
.SingleOrDefaultAsync(r => r.Name == "Admin", cancellationToken);
if (role is null)
{
role = new Role { Name = "Admin" };
context.Roles.Add(role);
await context.SaveChangesAsync(cancellationToken);
}
var permission = await context.Permissions
.SingleAsync(p => p.Code == "users.manage", cancellationToken);
var hasMapping = await context.RolePermissions
.AnyAsync(rp => rp.RoleId == role.Id && rp.PermissionId == permission.Id, cancellationToken);
if (!hasMapping)
{
context.RolePermissions.Add(new RolePermission
{
RoleId = role.Id,
PermissionId = permission.Id
});
await context.SaveChangesAsync(cancellationToken);
}
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
Use transactions when partial seed state would be harmful. However, avoid wrapping EF Core migration operations in unsupported explicit transaction patterns. Know the difference between application seed transactions and EF migration execution behavior.
Seed Data Versioning
Seed data changes should be versioned carefully.
Examples:
- Add new permission code.
- Rename a status display name.
- Remove an obsolete lookup value.
- Add a new default configuration key.
- Backfill a value for existing customers.
For static reference data controlled by the application, HasData plus migrations can version changes.
For dynamic or state-dependent changes, use migrations with custom SQL or controlled runtime seed jobs.
Example migration backfill:
migrationBuilder.Sql("""
UPDATE CustomerSettings
SET TimeZone = 'UTC'
WHERE TimeZone IS NULL
""");
Best practice:
- Treat seed changes as part of deployment.
- Review generated migration operations.
- Avoid destructive updates unless intentional.
- Document business meaning of seed values.
- Use stable codes for business logic instead of display names.
- Avoid deleting lookup values still referenced by existing rows.
Soft-Deleting or Retiring Seeded Reference Data
Deleting seeded lookup data can break historical records.
Example:
OrderStatus: Cancelled
If historical orders reference Cancelled, deleting that status can violate foreign keys or make old data unreadable.
Better pattern:
public sealed class OrderStatus
{
public int Id { get; set; }
public string Code { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public bool IsActive { get; set; }
}
Instead of deleting:
status.IsActive = false;
This preserves historical data while preventing new usage.
For HasData, changing IsActive from true to false can be migration-managed if the data is static.
HasData vs Runtime Seed Logic
The main comparison:
Rule of thumb:
Use HasData for small static reference data controlled by migrations.
Use UseSeeding / UseAsyncSeeding or controlled runtime logic for general-purpose seeding that must inspect the database, use generated keys, call services, or vary by environment.
Seeding in Clean Architecture
In Clean Architecture, seeding usually belongs in the Infrastructure or Persistence layer, not the Domain layer.
Example structure:
MyApp.Domain
Entities
ValueObjects
MyApp.Application
Use cases
Interfaces
MyApp.Infrastructure
AppDbContext
Entity configurations
Seeders
MyApp.Api
Program.cs
A seeder can be registered as a service:
public interface IDatabaseSeeder
{
Task SeedAsync(CancellationToken cancellationToken = default);
}
Implementation:
public sealed class DatabaseSeeder : IDatabaseSeeder
{
private readonly AppDbContext _context;
public DatabaseSeeder(AppDbContext context)
{
_context = context;
}
public async Task SeedAsync(CancellationToken cancellationToken = default)
{
if (!await _context.Roles.AnyAsync(r => r.Name == "Admin", cancellationToken))
{
_context.Roles.Add(new Role { Name = "Admin" });
await _context.SaveChangesAsync(cancellationToken);
}
}
}
For production, run this in a controlled deployment/init process, not randomly during normal request processing.
Seeding in Docker and Cloud Deployments
In Docker, Kubernetes, Azure App Service, Azure Container Apps, or other cloud environments, multiple instances may start at the same time. This affects seeding.
Risky pattern:
Every app instance starts -> every app instance runs migrations and seed logic
Safer patterns:
- Run migrations and seed logic as a separate deployment step.
- Use a one-off Kubernetes Job.
- Use a CI/CD database migration stage.
- Use an EF migration bundle.
- Use SQL scripts reviewed before production.
- Use a controlled admin/init tool.
- Use
UseSeedingwith migration operations when appropriate. - Ensure idempotency and uniqueness constraints.
Cloud deployments should avoid requiring the main application identity to have schema modification permissions unless carefully justified.
Security Considerations
Seed logic can create security risks.
Common risks:
- Hard-coded admin passwords.
- Default credentials left enabled.
- Secrets committed to source control.
- Overly privileged default users.
- Seeding production with development accounts.
- Logging seed passwords.
- Not forcing password reset.
- Not auditing who ran seed logic.
- Running seed logic repeatedly and resetting credentials unexpectedly.
Better practices:
- Use secret stores for temporary credentials.
- Use environment-specific configuration.
- Force password change on first login.
- Disable or remove bootstrap seed logic after setup.
- Use least privilege.
- Log non-sensitive seed events.
- Never log passwords or tokens.
- Protect seed tools and migration jobs.
Common Mistakes
Common mistakes include:
- Using
HasDatafor dynamic or environment-specific data. - Using
HasDatafor password hashes or Identity users. - Forgetting to specify primary keys in
HasData. - Changing
HasDataprimary keys and causing delete/insert operations. - Seeding with
DateTime.NoworGuid.NewGuid()in model configuration. - Putting large datasets into migrations.
- Running seed logic in every app instance on startup.
- Not making runtime seed logic idempotent.
- Not enforcing unique constraints for seeded natural keys.
- Seeding development demo data into production.
- Calling
EnsureCreatedandMigratetogether. - Assuming
EnsureCreatedupdates existing databases. - Not reviewing generated migrations from
HasData. - Deleting seeded lookup data that historical rows still reference.
- Storing seed passwords in source control.
- Ignoring concurrency in cloud deployments.
Best Practices
Choose the seeding strategy based on data lifecycle.
Use HasData only for small, stable, deterministic reference data.
Use UseSeeding and UseAsyncSeeding for general-purpose EF Core seeding logic.
Implement both synchronous and asynchronous seeding methods when using EF Core seeding options.
Make runtime seed logic idempotent.
Add database unique constraints for natural keys such as role name, permission code, country code, and setting key.
Use migrations or reviewed scripts for production schema and data changes.
Avoid running high-risk seed logic automatically in every production app instance.
Use separate migration/init jobs in cloud deployments.
Do not use HasData for sensitive default users or passwords.
Keep test and demo seed data separate from production seed data.
Review generated migrations.
Avoid large seed data in migration snapshots.
Use transactions for multi-step seed operations when partial state would be harmful.
Treat seeding as part of deployment and operations, not just application startup code.