Entity Models & Migrations
Here's the mental model to carry through this whole phase: a class is a table. You write an ordinary C# class, EF Core looks at it, and it figures out what the table should look like — the columns, the types, the primary key — mostly without you saying a word. When you later change that class, you don't reach for a SQL editor. You generate a migration: a small, versioned record of "the schema went from this to that," which you commit alongside your code and apply to the database when you deploy.
So there are two halves to learn. First, how EF Core reads your classes (and how you nudge it when its guesses aren't quite right). Second, how migrations turn those classes into real, evolving database schema you can trust in production.
📝 In Phase 1 you built a
DbContextwithDbSet<T>properties. EachDbSet<Blog> Blogsis one table — but EF Core needs the actualBlogclass to know what goes in it. That class is what we're writing now.
A class is a table
EF Core works from conventions — sensible defaults it applies automatically so you write less configuration. The big ones:
- A property named
Id(or<TypeName>Id, likeBlogId) becomes the primary key. - Every public property with a getter and setter becomes a column.
- The column's SQL type is inferred from the C# type (
int→ integer,string→ text,DateTime→ timestamp, and so on). - The table name comes from the
DbSetproperty name on your context — soDbSet<Blog> Blogsproduces a table calledBlogs.
Let's give our blog a Post entity:
public class Post
{
public int Id { get; set; }
[Required, MaxLength(200)]
public string Title { get; set; } = "";
public string Content { get; set; } = "";
}
What just happened: EF Core reads this class and concludes: a table with an Id column as the primary key (and, because it's an int named Id, an auto-incrementing one), a Title text column, and a Content text column. The [Required, MaxLength(200)] on Title is the one place we overrode a convention — more on that in a second. The = "" initializers aren't an EF thing; they just keep C#'s nullable-reference warnings quiet by giving the strings a non-null default.
And the matching Blog:
public class Blog
{
public int Id { get; set; }
public string Url { get; set; } = "";
}
What just happened: Same story — Id is the key, Url is a column. No annotations here, so EF Core uses pure conventions: Url becomes a nullable-or-not text column with no length cap. We'll tighten that up next, because "no length cap" is rarely what you actually want in a database.
💡 Conventions are doing real work for you. You didn't declare a single column type, key, or constraint by hand — you described your data as C# types and EF Core inferred a reasonable schema. You only step in when a guess is wrong.
Two ways to override: annotations vs the Fluent API
When the conventions aren't enough — you need a max length, a different column name, a column that isn't mapped at all — you have two tools.
Data annotations are attributes you put right on the property. They live with the model, which makes them easy to read:
| Annotation | What it does |
|---|---|
[Required] |
Column is NOT NULL |
[MaxLength(200)] |
Caps string/array length (e.g. varchar(200)) |
[Column("url")] |
Maps the property to a differently named column |
[Key] |
Marks the primary key explicitly (when convention can't guess) |
[NotMapped] |
Exclude this property — no column for it |
The Fluent API is the other option: you configure everything in code inside your DbContext, in an OnModelCreating override.
public class BloggingContext : DbContext
{
public DbSet<Blog> Blogs => Set<Blog>();
public DbSet<Post> Posts => Set<Post>();
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<Blog>()
.Property(x => x.Url)
.IsRequired()
.HasMaxLength(200);
}
}
What just happened: We told EF Core that Blog.Url is required and capped at 200 characters — the same kind of rule [Required, MaxLength(200)] expresses, but written centrally instead of on the property. ModelBuilder is the configuration surface; b.Entity<Blog>().Property(...) drills down to one column and chains the rules onto it.
Why two systems? Annotations are concise and live next to the data, which is great for simple rules. But the Fluent API is the more powerful of the two — it can express things annotations can't (composite keys, relationships, indexes, default values, and much more), and it keeps your entity classes free of EF-specific attributes if you want them to stay plain. If you ever configure the same thing both ways, the Fluent API wins. That precedence is worth memorizing: a confusing "but I set [MaxLength]!" bug is almost always a Fluent API line quietly overriding it.
⚠️ Don't sprinkle both for the same property and hope for the best. Pick a default approach for your project (many teams lean Fluent API for anything non-trivial) and reserve mixing for deliberate overrides you actually understand.
Migrations: versioning your schema
You've got classes. Now you need an actual database with actual tables — and, crucially, a way to change that schema later without losing data or guessing at hand-written ALTER TABLE statements. That's what migrations are.
First, install the design-time pieces. The Design package powers the tooling; the dotnet-ef global tool gives you the commands:
Now create your first migration:
What just happened: EF Core compared your current model (the Blog and Post classes plus any Fluent config) against the last migration — and since there isn't one yet, the "diff" is "create everything." It wrote a new C# migration class into a Migrations/ folder. That class has two methods: Up() (apply the change) and Down() (undo it). Nothing has touched your database yet — a migration is just a plan.
Here's a peek at what that generated class looks like:
public partial class InitialCreate : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Blogs",
columns: table => new
{
Id = table.Column<int>(nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Url = table.Column<string>(maxLength: 200, nullable: false)
},
constraints: table => table.PrimaryKey("PK_Blogs", x => x.Id));
// ... CreateTable for "Posts" follows ...
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "Blogs");
// ... DropTable for "Posts" ...
}
}
What just happened: The migration describes your schema as code, not raw SQL — Up() creates the tables (notice Url came out as maxLength: 200, nullable: false, exactly the Fluent rule we set), and Down() drops them so the change is reversible. EF Core translates this to dialect-specific SQL at apply time, which is why the same migration can target SQLite, SQL Server, or Postgres.
Now apply it:
What just happened: EF Core ran the Up() of every pending migration against your database — creating the Blogs and Posts tables for real. It also created a bookkeeping table (__EFMigrationsHistory) that records which migrations have been applied, so next time it only runs the new ones. Run database update again right now and nothing happens: there's nothing pending.
If you want to see the SQL without touching the database, dotnet ef migrations script prints it. Get in the habit of reading it — it's the same instinct from Phase 1 of watching the SQL EF Core generates.
Migrations vs EnsureCreated() — don't mix them
There's a tempting shortcut you'll see in tutorials: ctx.Database.EnsureCreated(). It looks at your model and creates the schema in one shot, no migration files, no tooling.
// Dev/prototyping only — NOT a migration.
ctx.Database.EnsureCreated();
What just happened: EF Core created the tables directly from your current model. Fast and convenient for a throwaway prototype or a test database. But notice what it didn't do: it created no migration, recorded nothing in the history table, and gave you no Down() to reverse anything.
⚠️
EnsureCreated()and migrations are two different worlds, and they don't cooperate.EnsureCreated()creates the schema once and has no concept of evolving it — there's no "add a column later." Worse, a database made byEnsureCreated()has no migrations history, sodatabase updatewon't know where to start. Pick one per database. For anything real, that's migrations.
A migration per change, committed and deployed
The workflow once you're rolling is rhythmic, and it's worth internalizing:
- Change a model (add a property, a new entity, a constraint).
dotnet ef migrations add DescribeTheChange— generates the diff.- Review the generated
Up()/Down(). Did it do what you expected? - Commit the migration files with the code change — they're part of your source history.
- On deploy, run
dotnet ef database update(or apply the migration as part of your release).
Each model change earns its own migration. That gives you a readable, reviewable timeline of how your schema evolved — and the ability to roll forward or back deliberately.
💡 Treat migration files like code, because they are. They get reviewed in pull requests, they live in version control, and the order they're applied in matters. We'll cover the production side — applying migrations safely on a live database, and handling concurrency — in Phase 8: Transactions & Migrations in Production. For now, the habit to build is: model change → migration → commit.
Recap
- A class is a table. EF Core reads your plain C# entity classes and maps them by convention:
Id/<Type>Idbecomes the primary key, public properties become columns, types are inferred, and the table name comes from theDbSetname. - Override conventions with data annotations (
[Required],[MaxLength],[Column],[Key],[NotMapped]) on the property, or with the Fluent API inOnModelCreating. The Fluent API is more powerful and wins when both configure the same thing. - Migrations version your schema. Install
Microsoft.EntityFrameworkCore.Designand thedotnet-eftool, thendotnet ef migrations add <Name>generates a reversibleUp()/Down()class from the diff, anddotnet ef database updateapplies pending migrations. - A migration is a plan, not an action — nothing hits the database until
database update. EF Core tracks applied migrations in__EFMigrationsHistory. EnsureCreated()is a dev-only shortcut that creates schema once with no migration history — never mix it with migrations on the same database.- Make one migration per model change, review it, commit it with the code, and apply it on deploy.
Quick check
[
{
"q": "You have a Blog class with an int property named Id and a public string Url. With pure EF Core conventions, what schema does EF infer?",
"choices": ["No table at all until you add [Table] and [Key] attributes", "A Blogs table with Id as the primary key and Url as a column", "A Blog table with no primary key", "A table where only Url is mapped, because Id is reserved"],
"answer": 1,
"explain": "Conventions: Id becomes the primary key, public properties become columns, and the table name comes from the DbSet name (Blogs)."
},
{
"q": "A property has [MaxLength(50)] in an annotation, but OnModelCreating also calls HasMaxLength(200) for it. What max length does the column get?",
"choices": ["50, annotations take priority", "200, the Fluent API wins", "It throws an error for conflicting config", "The smaller of the two, 50"],
"answer": 1,
"explain": "When both configure the same thing, the Fluent API wins. That precedence is a common source of 'but I set the annotation!' confusion."
},
{
"q": "What does `dotnet ef migrations add InitialCreate` do?",
"choices": ["Immediately creates the tables in the database", "Generates a versioned migration class with Up/Down from the model diff, without touching the database", "Deletes and recreates the database from scratch", "Runs EnsureCreated() for you"],
"answer": 1,
"explain": "migrations add only generates the migration (a plan). Nothing reaches the database until you run dotnet ef database update."
}
]
← Phase 1: What EF Core Is & the DbContext · Guide overview · Phase 3: Create & Read →
Check your understanding
1. You have a Blog class with an int property named Id and a public string Url. With pure EF Core conventions, what schema does EF infer?
2. A property has [MaxLength(50)] in an annotation, but OnModelCreating also calls HasMaxLength(200) for it. What max length does the column get?
3. What does `dotnet ef migrations add InitialCreate` do?