# Seeding Data in EF Core: Strategies and Best Practices

> EF Core offers several approaches for seeding data - from HasData in model configuration to custom initialization logic and SQL scripts. Here is when to use each strategy.

Published: 2026-08-24. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/seeding-data-ef-core

EF Core gives you several seeding mechanisms, and the right one depends on the data.
Use `HasData` for small, static lookup tables with fixed primary keys.
Reach for `UseSeeding` and `UseAsyncSeeding` (EF Core 9+) or a custom initializer when the data needs domain logic and relationships.
For large reference datasets, run raw SQL inside a migration.

Reference data, development fixtures, and production bootstrap data have different lifecycles.
Using one seeding mechanism for all three creates non-deterministic migrations or lets sample data leak into production.

## Why Seed Data?

**Seeding** is inserting a known set of starting rows into the database so the application has the data it needs to run.
Every application needs some initial data: lookup tables, default roles, configuration records, test data for development.
Without a consistent seeding strategy, developers end up with manual SQL scripts scattered across the team or databases in unpredictable states.

EF Core provides built-in seeding through `HasData`, but that's just one option. The right strategy depends on what you're seeding and when.

![A decision tree choosing a seeding strategy: HasData for static lookups with fixed keys, UseSeeding or a custom initializer when domain logic and relationships are needed, and raw SQL in a migration for large reference datasets](https://milanjovanovic.tech/blogs/articles/seeding-data-ef-core/seeding-strategy-decision.png)

## HasData: Built-in Model Seeding

The `HasData` method in your entity configuration tells EF Core to include seed data in [migrations](https://milanjovanovic.tech/blog/ef-core-migrations-best-practices):

```csharp
public class OrderStatusConfiguration : IEntityTypeConfiguration<OrderStatus>
{
    public void Configure(EntityTypeBuilder<OrderStatus> builder)
    {
        builder.HasKey(x => x.Id);

        builder.Property(x => x.Name).HasMaxLength(50);

        builder.HasData(
            new OrderStatus { Id = 1, Name = "Draft" },
            new OrderStatus { Id = 2, Name = "Confirmed" },
            new OrderStatus { Id = 3, Name = "Shipped" },
            new OrderStatus { Id = 4, Name = "Delivered" },
            new OrderStatus { Id = 5, Name = "Cancelled" });
    }
}
```

When you create a migration, EF Core generates `InsertData` operations:

```csharp
migrationBuilder.InsertData(
    table: "OrderStatuses",
    columns: new[] { "Id", "Name" },
    values: new object[,]
    {
        { 1, "Draft" },
        { 2, "Confirmed" },
        { 3, "Shipped" },
        { 4, "Delivered" },
        { 5, "Cancelled" }
    });
```

### HasData Limitations

`HasData` has strict rules:

- **Primary keys are required** - you must specify the key value for every seeded entity. No auto-generated keys.
- **No navigation properties** - you can't set related entities directly. Use foreign key values instead.
- **Tracked by migrations** - any change to seed data generates a new migration.
- **No access to services or configuration** - the values are baked into the model, so you can't read from IConfiguration or hash a password with an injected service.

That last one bites people seeding an admin user: you can't call your password hasher from `HasData`.
That's a job for custom seeding code.

For [entity relationships](https://milanjovanovic.tech/blog/entity-relationships-ef-core), seed related entities separately:

```csharp
builder.HasData(
    new Role { Id = 1, Name = "Admin" });

// In a separate configuration
permissionBuilder.HasData(
    new Permission { Id = 1, Name = "users.read", RoleId = 1 },
    new Permission { Id = 2, Name = "users.write", RoleId = 1 });
```

## UseSeeding and UseAsyncSeeding (EF Core 9+)

EF Core 9 added a first-class hook for custom seed logic: `UseSeeding` and `UseAsyncSeeding` on the options builder.

```csharp
builder.Services.AddDbContext<AppDbContext>(options =>
    options
        .UseNpgsql(connectionString)
        .UseAsyncSeeding(async (context, _, ct) =>
        {
            var hasRoles = await context.Set<Role>().AnyAsync(ct);
            if (hasRoles)
            {
                return;
            }

            context.Set<Role>().AddRange(
                new Role("Admin"),
                new Role("User"));

            await context.SaveChangesAsync(ct);
        }));
```

The seeding delegate runs as part of `EnsureCreated`, `Migrate`, and `dotnet ef database update`.
Unlike `HasData`, you get a live `DbContext`: navigation properties, domain methods, and conditional logic all work.

Two things to keep in mind:

- Implement **both** `UseSeeding` and `UseAsyncSeeding` if you mix sync and async database creation paths (EF only calls the one matching the API used).
- The delegate runs every time migrations are applied, so the logic must be idempotent (more on that below).

## Custom Initialization Logic

For more complex seeding, run custom code after your `DbContext` is configured. I typically create a `DbInitializer` class:

```csharp
public static class DbInitializer
{
    public static async Task SeedAsync(AppDbContext context)
    {
        if (await context.Roles.AnyAsync())
        {
            return; // Already seeded
        }

        var adminRole = new Role("Admin");
        adminRole.AddPermission("users.read");
        adminRole.AddPermission("users.write");
        adminRole.AddPermission("orders.manage");

        var userRole = new Role("User");
        userRole.AddPermission("users.read");

        context.Roles.AddRange(adminRole, userRole);

        await context.SaveChangesAsync();
    }
}
```

Call it during application startup:

```csharp
var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
    var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    await DbInitializer.SeedAsync(context);
}

app.Run();
```

This approach lets you use navigation properties, domain logic, and computed values. It also works well with [DDD patterns](https://milanjovanovic.tech/blog/value-objects-in-dotnet-ddd-fundamentals) where entities have private setters and factory methods.

## Idempotent Seeding

Seed logic must be idempotent - running it multiple times should produce the same result. There are several patterns for this:

### Check-Before-Insert

```csharp
public static async Task SeedCurrenciesAsync(AppDbContext context)
{
    var existing = await context.Currencies
        .Select(c => c.Code)
        .ToHashSetAsync();

    var currencies = new List<Currency>
    {
        new("USD", "US Dollar"),
        new("EUR", "Euro"),
        new("GBP", "British Pound")
    };

    var newCurrencies = currencies
        .Where(c => !existing.Contains(c.Code))
        .ToList();

    if (newCurrencies.Count > 0)
    {
        context.Currencies.AddRange(newCurrencies);
        await context.SaveChangesAsync();
    }
}
```

### Upsert Pattern

For data that might change between deployments:

```csharp
public static async Task SeedConfigAsync(AppDbContext context)
{
    var configs = new Dictionary<string, string>
    {
        ["MaxRetryCount"] = "3",
        ["SessionTimeout"] = "30",
        ["DefaultPageSize"] = "25"
    };

    foreach (var (key, value) in configs)
    {
        var existing = await context.AppConfigs
            .FirstOrDefaultAsync(c => c.Key == key);

        if (existing is null)
        {
            context.AppConfigs.Add(new AppConfig(key, value));
        }
        else
        {
            existing.UpdateValue(value);
        }
    }

    await context.SaveChangesAsync();
}
```

## Migration-Based Seeding

Sometimes you want seed data tied to a specific migration. Use the `Up` method of a migration to insert data:

```csharp
public partial class AddDefaultCategories : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.InsertData(
            table: "Categories",
            columns: new[] { "Id", "Name", "SortOrder" },
            values: new object[,]
            {
                { new Guid("018e5f3a-0001-7000-8000-000000000001"), "Electronics", 1 },
                { new Guid("018e5f3a-0001-7000-8000-000000000002"), "Clothing", 2 },
                { new Guid("018e5f3a-0001-7000-8000-000000000003"), "Books", 3 }
            });
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DeleteData(
            table: "Categories",
            keyColumn: "Id",
            keyValues: new object[]
            {
                new Guid("018e5f3a-0001-7000-8000-000000000001"),
                new Guid("018e5f3a-0001-7000-8000-000000000002"),
                new Guid("018e5f3a-0001-7000-8000-000000000003")
            });
    }
}
```

This approach guarantees the data is inserted exactly once and in the right order relative to schema changes.

## SQL Scripts

For large reference datasets or data that requires database-specific features, use raw SQL in migrations:

```csharp
public partial class SeedCountries : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql(
            File.ReadAllText("Migrations/Scripts/seed_countries.sql"));
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql("DELETE FROM Countries;");
    }
}
```

The SQL script approach gives you full control. You can use database-specific syntax, `MERGE` statements for upserts, and handle thousands of rows efficiently.

One caveat with `File.ReadAllText`: the path resolves against the working directory when the migration runs, which differs between `dotnet ef database update` and applying migrations at startup.
Embed the script as an assembly resource instead if you apply migrations from multiple places:

```csharp
migrationBuilder.Sql(
    ResourceHelper.ReadEmbedded("Migrations.Scripts.seed_countries.sql"));
```

Migration-based seeding also plays well with [zero-downtime deployments](https://milanjovanovic.tech/blog/zero-downtime-migrations-ef-core), because the data ships atomically with the schema change that needs it.

## Environment-Specific Seeding

Development needs test data. Production doesn't. Separate them:

```csharp
using (var scope = app.Services.CreateScope())
{
    var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();

    // Always seed reference data
    await DbInitializer.SeedReferenceDataAsync(context);

    // Only seed test data in development
    if (app.Environment.IsDevelopment())
    {
        await DbInitializer.SeedTestDataAsync(context);
    }
}
```

Keep reference data (currencies, countries, roles) separate from test data (fake users, sample orders). Reference data goes to all environments. Test data stays in development.

For realistic test data at volume, use [**Bogus**](https://github.com/bchavez/Bogus) instead of hand-writing entities:

```csharp
var userFaker = new Faker<User>()
    .CustomInstantiator(f => new User(
        f.Name.FirstName(),
        f.Name.LastName(),
        f.Internet.Email()));

var users = userFaker.Generate(500);

context.Users.AddRange(users);
await context.SaveChangesAsync();
```

Five hundred believable users in four lines beats copy-pasting `new User(...)` blocks.

## Summary

Use `HasData` only for deterministic model-managed data with stable keys.
Use `UseSeeding` or an explicit initializer for idempotent bootstrap logic, and migrations or reviewed scripts when the data must move with a schema version.
Keep development fixtures separate so sample data can never become part of a production deployment by accident.

## Frequently asked questions

### What is the best way to seed data in EF Core?

It depends on the data. Use HasData for small, static lookup tables with fixed primary keys. Use the UseSeeding and UseAsyncSeeding methods (EF Core 9+) or a custom initializer for entities with domain logic and relationships. Use raw SQL in migrations for large reference datasets.

### What is the difference between HasData and UseSeeding in EF Core?

HasData is model-managed seed data: it requires explicit primary keys, supports no navigation properties, and generates migration operations when it changes. UseSeeding (added in EF Core 9) runs custom code when the database is created or migrated, so you can use normal entity construction, domain methods, and conditional logic.

### Does HasData run every time the application starts?

No. HasData generates InsertData operations inside migrations, so the data is inserted when the migration is applied, not at application startup. Custom seeding code you call at startup runs on every start, which is why it must be idempotent.

### How do I seed different data per environment in EF Core?

Split your seeding into reference data (roles, currencies, statuses) that runs everywhere and test data that only runs in development. Check IHostEnvironment.IsDevelopment at startup and call the test data seeder conditionally.

### Can I use auto-generated keys with HasData?

No. HasData requires you to specify the primary key value for every seeded entity, even if the column is normally database-generated. This is how EF Core tracks changes to seed data across migrations.
