Seeding in EF Core: Model Seeding with HasData and Application-Side Seeding
Two ways to add initial data in EF Core: model seeding with HasData and the InsertData/UpdateData/DeleteData it generates, the key and navigation rules, the dynamic value trap; then a rerunnable application-side seeder and a pointer to EF Core 9's UseSeeding.
Version note: The code in this post is written for EF Core 6 and runs unchanged on EF Core 10.
Guess First: What Happens Without an Id?
The bookstore app has a few records that must exist in every environment: the initial authors, a few books, fixed tags. You can give these to EF Core as part of the model. Look at this line:
modelBuilder.Entity<Author>().HasData(
new Author { Id = 1, Name = "Ursula K. Le Guin" },
new Author { Name = "Isaac Asimov" });
The Id column is AUTOINCREMENT in SQLite. When you normally add a new Author, you do not set an Id; the database generates one. So what happens to the record on the second line, the one without an Id? Does the database give it 2?
No, it never reaches the database. While building the model, both EF Core 6 and EF Core 10 throw the same error:
Unhandled exception. System.InvalidOperationException: The seed entity for entity type 'Author' cannot be added because a non-zero value is required for property 'Id'. Consider providing a negative value to avoid collisions with non-seed data.
The reason lies in the nature of HasData. This data is not an INSERT command; it is the model itself. EF Core stores seed records in the model snapshot just like tables and columns, and computes the difference between two models by primary key. For a record without a key, there is no way to answer "did it change, was it deleted". In this post we will see the convenience and the limits that come with this idea, then look at doing the same job from application code.
Model Seeding with HasData
The model is a short version of the Bookstore model from the earlier posts: Author, Book, and Tag with a many-to-many relationship.
using Microsoft.EntityFrameworkCore;
namespace Bookstore;
public class Author
{
public int Id { get; set; }
public string Name { get; set; } = null!;
public List<Book> Books { get; set; } = new();
}
public class Book
{
public int Id { get; set; }
public string Title { get; set; } = null!;
public decimal Price { get; set; }
public int AuthorId { get; set; }
public Author Author { get; set; } = null!;
public List<Tag> Tags { get; set; } = new();
}
public class Tag
{
public int Id { get; set; }
public string Name { get; set; } = null!;
public List<Book> Books { get; set; } = new();
}
Seed data goes into OnModelCreating, with a separate HasData call for each entity:
public class BookstoreContext : DbContext
{
public DbSet<Author> Authors => Set<Author>();
public DbSet<Book> Books => Set<Book>();
public DbSet<Tag> Tags => Set<Tag>();
protected override void OnConfiguring(DbContextOptionsBuilder options)
=> options.UseSqlite("Data Source=bookstore.db");
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Author>().HasData(
new Author { Id = 1, Name = "Ursula K. Le Guin" },
new Author { Id = 2, Name = "Isaac Asimov" });
// The relationship is set with the foreign key value, not the navigation
modelBuilder.Entity<Book>().HasData(
new Book { Id = 1, Title = "A Wizard of Earthsea", Price = 12.50m, AuthorId = 1 },
new Book { Id = 2, Title = "The Left Hand of Darkness", Price = 14.00m, AuthorId = 1 },
new Book { Id = 3, Title = "Foundation", Price = 11.00m, AuthorId = 2 });
modelBuilder.Entity<Tag>().HasData(
new Tag { Id = 1, Name = "fantasy" },
new Tag { Id = 2, Name = "science-fiction" });
// The join table has no class; it is seeded with anonymous types
modelBuilder.Entity<Book>()
.HasMany(b => b.Tags)
.WithMany(t => t.Books)
.UsingEntity(j => j.HasData(
new { BooksId = 1, TagsId = 1 },
new { BooksId = 2, TagsId = 2 },
new { BooksId = 3, TagsId = 2 }));
}
}
Seed data reaches the database in two ways: through migrations or through EnsureCreated(). For a quick try, the second is enough:
using Bookstore;
using Microsoft.EntityFrameworkCore;
using var db = new BookstoreContext();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
foreach (var book in db.Books.Include(b => b.Author).Include(b => b.Tags).OrderBy(b => b.Id))
{
Console.WriteLine($"{book.Title} - {book.Author.Name} [{string.Join(", ", book.Tags.Select(t => t.Name))}]");
}
A Wizard of Earthsea - Ursula K. Le Guin [fantasy]
The Left Hand of Darkness - Ursula K. Le Guin [science-fiction]
Foundation - Isaac Asimov [science-fiction]
We never called Add or SaveChanges. The seed rows arrived together with the tables when they were created.
How Seed Data Gets into a Migration
Run dotnet ef migrations add InitialCreate with the same model and the Up method gets InsertData calls between the commands that create the tables. A part of the code EF Core 6 generated:
migrationBuilder.InsertData(
table: "Authors",
columns: new[] { "Id", "Name" },
values: new object[] { 1, "Ursula K. Le Guin" });
migrationBuilder.InsertData(
table: "Books",
columns: new[] { "Id", "AuthorId", "Price", "Title" },
values: new object[] { 3, 2, 11.00m, "Foundation" });
migrationBuilder.InsertData(
table: "BookTag",
columns: new[] { "BooksId", "TagsId" },
values: new object[] { 1, 1 });
EF Core 6 writes a separate InsertData for every row. EF Core 10 groups the rows of a table into a single call with a two-dimensional array, values: new object[,] { ... }; the result is the same. Notice that the values are written into the migration file as constants. Once a migration is generated, it is cut off from your seed code; that migration always inserts the values from that moment.
Changing Seed Data: UpdateData and DeleteData
Now for the strong side of model seeding. Change Foundation's price to 9.99 and remove the link between The Left Hand of Darkness and the science-fiction tag (the new { BooksId = 2, TagsId = 2 } line). Then add a new migration:
dotnet ef migrations add UpdateSeedData
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "BookTag",
keyColumns: new[] { "BooksId", "TagsId" },
keyValues: new object[] { 2, 2 });
migrationBuilder.UpdateData(
table: "Books",
keyColumn: "Id",
keyValue: 3,
column: "Price",
value: 9.99m);
}
EF Core compared the old seed list in the snapshot with the new one by key. For the row whose key stayed the same, it updated only the changed column; the row that disappeared from the list was deleted. The SQLite script for this migration is what you would expect:
DELETE FROM "BookTag"
WHERE "BooksId" = 2 AND "TagsId" = 2;
SELECT changes();
UPDATE "Books" SET "Price" = '9.99'
WHERE "Id" = 3;
SELECT changes();
Price is written in quotes as '9.99' because the SQLite provider stores decimal in a TEXT column. One thing needs saying plainly here: the seed rows now belong to the migrations. If someone changed Foundation's price to 10.50 by hand in production, the next seed change overwrites that column with your value. DeleteData does not check whether the row is still referenced in the database either.
The HasData Rule for Relationships
HasData does not accept an object graph. If you try to give the author inside the book, through the navigation property:
new Book { Id = 3, Title = "Foundation", Price = 11.00m, Author = new Author { Id = 2, Name = "Isaac Asimov" } }
Unhandled exception. System.InvalidOperationException: The seed entity for entity type 'Book' cannot be added because it has the navigation 'Author' set. To seed relationships, add the entity seed to 'Book' and specify the foreign key values {'AuthorId'}. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see the involved property values.
The message also gives the fix: each entity stays in its own HasData call and relationships are set with foreign key values. In a many-to-many relationship there is no join entity class at all; EF Core creates the join table itself, named BookTag, with BooksId and TagsId columns. That is why we used anonymous types carrying these column names inside UsingEntity(j => j.HasData(...)) above. Get a column name wrong and EF Core again objects while building the model. If you defined the join entity yourself (like the join entity with a payload in the previous post), you write a normal HasData for it.
The Limits of HasData
The rules of model seeding follow from one sentence: seed data is part of the model. Let us list the consequences.
The key is mandatory. You provide the value even for database-generated keys. The suggestion of negative values in the error message is there to avoid collisions with records the app adds the normal way. In SQLite, AUTOINCREMENT continues above the largest value, so using 1, 2, 3 caused no trouble; but with another provider, or with rows added by hand, you need to think about collisions.
Values must be constant. Building the model must give the same result every time; a value that changes on every call, like DateTime.UtcNow or Guid.NewGuid(), pollutes every migration (we will see this in the pitfalls section).
You cannot ask the database anything. HasData runs while the model is being built; there is no connection at that point. You cannot write logic like "check whether this author exists first, add it if not".
It is not for large data. Every seed row is written as C# code into the snapshot file and into the migration. A few dozen rows are fine; a ten-thousand-row city list bloats build times and file sizes.
It does not vary by environment. Seed data goes to every database as is; you cannot say "a hundred test orders in development, none in production".
So HasData is ideal for reference data that never changes or changes very rarely. Tags, country codes, order statuses and the like.
Seeding from the Application
For everything else, the most flexible way is to write seeding as ordinary application code. This time the model has no HasData:
using Microsoft.EntityFrameworkCore;
namespace Bookstore;
public static class BookstoreSeeder
{
public static async Task SeedAsync(BookstoreContext db)
{
if (await db.Authors.AnyAsync())
{
Console.WriteLine("Seed skipped: data already exists.");
return;
}
var fantasy = new Tag { Name = "fantasy" };
var sciFi = new Tag { Name = "science-fiction" };
// No keys here; the database generates them and we can build a graph with navigations
db.Authors.AddRange(
new Author
{
Name = "Ursula K. Le Guin",
Books =
{
new Book { Title = "A Wizard of Earthsea", Price = 12.50m, Tags = { fantasy } },
new Book { Title = "The Left Hand of Darkness", Price = 14.00m, Tags = { sciFi } }
}
},
new Author
{
Name = "Isaac Asimov",
Books = { new Book { Title = "Foundation", Price = 11.00m, Tags = { sciFi } } }
});
var count = await db.SaveChangesAsync();
Console.WriteLine($"Seed inserted {count} rows.");
}
}
At startup, migrations run first, then the seed:
using Bookstore;
using Microsoft.EntityFrameworkCore;
await using var db = new BookstoreContext();
await db.Database.MigrateAsync();
await BookstoreSeeder.SeedAsync(db);
var books = await db.Books.OrderBy(b => b.Id).Select(b => new { b.Id, b.Title, Author = b.Author.Name }).ToListAsync();
foreach (var b in books)
{
Console.WriteLine($"{b.Id}: {b.Title} ({b.Author})");
}
The first and the second run:
Seed inserted 10 rows.
1: A Wizard of Earthsea (Ursula K. Le Guin)
2: The Left Hand of Darkness (Ursula K. Le Guin)
3: Foundation (Isaac Asimov)
Seed skipped: data already exists.
1: A Wizard of Earthsea (Ursula K. Le Guin)
2: The Left Hand of Darkness (Ursula K. Le Guin)
3: Foundation (Isaac Asimov)
10 rows: two authors, three books, two tags and three links in the join table. Everything we could not do with HasData is allowed here. The database generated the keys, we built the relationships with navigations, and we could read the data from a file or an API if we wanted. With if (env.IsDevelopment()) you can also split the seed by environment.
It has a price too. This code is not in the migration scripts or the bundle; the app runs the seed. The "migrate at startup" risks from the previous post apply here as well: if three instances start at the same time, all three can get false from AnyAsync() and insert the same data three times.
Which One When?
A short decision list:
If the data is constant, small and the same in every environment, use HasData. Its changes are tracked in the migration history, go to production through scripts, and the DBA sees in SQL exactly what was added.
If the data is large, if the database must generate the keys, if it varies by environment or comes from another source, seed from the application. Always include the check (like AnyAsync()) so the seed can run again and again.
EF Core 9 introduced UseSeeding and UseAsyncSeeding, which formalize this second way. Your seed code is defined on DbContextOptionsBuilder and runs automatically during EnsureCreated, Migrate and dotnet ef database update. We will see it in the EF Core 9 post; until then, the seeder pattern above does the same job.
Common Pitfalls
The sneakiest pitfall is using a dynamic value inside HasData. Imagine you add a CreatedAt property to Author and write CreatedAt = DateTime.UtcNow in the seed. After generating and applying the first migration, run dotnet ef migrations add Nothing without changing anything in the model and EF Core 6 produces this "empty" migration:
migrationBuilder.UpdateData(
table: "Authors",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 11, 3, 21, 53, 37, 675, DateTimeKind.Utc).AddTicks(4360));
Since the value changes every time the model is built, EF Core sees a difference every time. EF Core 6 accepts this silently and you carry pointless UpdateData lines in every migration. With the same code, EF Core 10 stops dotnet ef database update on the very first run:
An error was generated for warning 'Microsoft.EntityFrameworkCore.Migrations.PendingModelChangesWarning': The model for context 'BookstoreContext' changes each time it is built. This is usually caused by dynamic values used in a 'HasData' call (e.g. `new DateTime()`, `Guid.NewGuid()`). Add a new migration and examine its contents to locate the cause, and replace the dynamic call with a static, hardcoded value. See https://aka.ms/efcore-docs-pending-changes. This exception can be suppressed or logged by passing event ID 'RelationalEventId.PendingModelChangesWarning' to the 'ConfigureWarnings' method in 'DbContext.OnConfiguring' or 'AddDbContext'.
The fix is the same in both versions: write a constant value such as new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc).
The second pitfall is changing a key in the seed. EF Core reads a row whose key changed as "the old row was deleted, a new row was added" and generates DeleteData and InsertData. If other records reference that row, cascade delete kicks in or you get a foreign key error.
The third is mixing HasData and application seeding on the same table. The if (await db.Authors.AnyAsync()) check in the seeder sees the two authors that came from HasData and never runs the seed. Write the check against the specific record you are looking for instead of "is there any row in the table", or use the two methods for different tables.
Finally, putting test data into HasData. Once test orders are in a migration, they go to production too. Test data belongs in the tests' own setup; we will cover testing in a separate post.
A Small Experiment
★☆☆ Add a third tag to the model with HasData (Id = 3, Name = "classic") and link Foundation to this tag as well. Generate a new migration and check exactly which InsertData calls appear in the Up method.
★★☆ Reproduce the CreatedAt = DateTime.UtcNow pitfall yourself. Run two migrations add commands back to back and look at the contents of the second, then make the value constant, repeat the experiment and see the second migration stay empty.
★★★ Make BookstoreSeeder safe to rerun: instead of "is there any author", check each author by name, add the missing ones and leave the existing ones alone. While the app is running, delete an author from the database, restart the app and verify that only that author comes back.
Our tables have data now. The next question is how we read it: which part of the LINQ expression you write is translated to SQL, which part runs in memory, and why can the difference between IQueryable and IEnumerable make a query pull the whole table? In the next post we move on to querying with LINQ.