Entity Framework Core: Getting Started with Code-First
Start with Entity Framework Core code first: entities, DbContext, provider choice, dotnet ef tooling, conventions vs fluent API, and your first migration.
DbContext exposes typed DbSet queries, a provider package connects to your database, and migrations turn model changes into schema changes. The trade-off versus hand-written SQL is a mapping layer to learn, and the payoff is compile-time-checked queries, one consistent write API, and a schema that lives in version control.The refactor that usually leads here: a service with SQL strings in a helper class, where a rename in C# compiles fine and blows up at runtime with SqliteException: no such column: tasks_task_title. Entity Framework Core removes that entire class of failure by making the query language C# itself, and the mapping between your classes and your tables explicit, testable code.
Entity Framework Core code first is the modeling style where C# classes come first and the schema follows: entities are plain classes, a DbContext maps them, and EF Core generates the database from the model. The official EF Core documentation is the reference; this page is the runnable path for a service.
The examples assume .NET SDK 10.0 with EF Core 10 (the EF Core major versions align with .NET’s) and continue the TaskApi, now with real storage. The Web API controllers from earlier in this series stay untouched; only the storage behind them changes.
After watching teams hand-roll SQL helpers for years, I have a firm view: EF Core is the right default for services because most of what hand-rolled SQL costs is invisible until the schema changes. Learn the mapping layer once, and the queries you write in C# are checked the moment you build. I’ve also written a separate breakdown of Entity Framework Core pros and cons for business apps, including how it compares with Dapper and raw SQL.
What code-first means in EF Core
Code-first is a direction of authority: the C# model is the source of truth, and the database schema is derived from it. You write classes, EF Core maps them to tables and columns, and migrations apply model changes to the database as versioned steps. The reverse, database-first, scaffolds C# from an existing schema. The comparison comes later in this page, and for services you own, code-first wins because schema changes flow through the same pull requests as the code that needs them.
CODE-FIRST DATABASE-FIRST
C# classes ----> schema existing schema ----> C# classes
(you own the model) (the DB owns the truth)
|
dotnet ef migrations
schema changes travel in git with the code
The complete data layer: entities and DbContext
Three files give the TaskApi real storage. First the entities, plain classes with no EF dependency in their bodies:
namespace TaskApi.Models;
public class Project
{
public Guid Id { get; set; }
public required string Name { get; set; }
public List<TaskItem> Tasks { get; set; } = [];
}
public class TaskItem
{
public Guid Id { get; set; }
public required string Title { get; set; }
public bool Done { get; set; }
public Guid ProjectId { get; set; }
public Project Project { get; set; } = null!;
public DateTime CreatedAtUtc { get; set; }
}
The relationship between Project and TaskItem is visible in the classes (a task has ProjectId plus a navigation to Project). How EF Core reads it, configures it, and loads it is its own guide on EF Core relationships. Second, the DbContext, the class that maps your model to a database:
using Microsoft.EntityFrameworkCore;
using TaskApi.Models;
namespace TaskApi.Data;
public sealed class TaskApiContext(DbContextOptions<TaskApiContext> options)
: DbContext(options)
{
public DbSet<Project> Projects => Set<Project>();
public DbSet<TaskItem> Tasks => Set<TaskItem>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Project>()
.Property(p => p.Name)
.HasMaxLength(200);
modelBuilder.Entity<TaskItem>()
.HasIndex(t => t.ProjectId);
modelBuilder.Entity<Project>()
.HasData(new Project { Id = Guid.Parse("0f8f4b7a-1111-4a1b-9c9c-2f2f2f2f2f2f"), Name = "inbox" });
}
}
That is the whole data layer: entities, one context class with a few mappings, done. OnModelCreating is where the fluent API customizes what conventions cannot see, covered in its own section below.
Provider choice: one package, one connection string
The provider is what makes EF Core database-agnostic: a NuGet package translates LINQ to a specific database’s SQL. For this tutorial, SQLite, because a file on disk needs no server. The swap to anything else is a package plus a connection string:
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
| Database | Package | Use it for |
|---|---|---|
| SQLite | Microsoft.EntityFrameworkCore.Sqlite | Tutorials, local development, small apps, tests |
| PostgreSQL | Npgsql.EntityFrameworkCore.PostgreSQL | Services in production, containers, cloud managed databases |
| SQL Server | Microsoft.EntityFrameworkCore.SqlServer | Microsoft-centric environments and existing SQL Server estates |
Registration wires the provider and the connection string into the container (connection strings live in dotnet user-secrets in development, never in committed files):
builder.Services.AddDbContext<TaskApiContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("TaskApi")));
// the PostgreSQL swap, once you run a server:
// options.UseNpgsql(builder.Configuration.GetConnectionString("TaskApi"));
AddDbContext registers the context with the scoped lifetime, one context per request, which is exactly what the dependency injection guide explains: a short-lived consumer of a long-lived connection pool. The modeling documentation covers the full range of context configuration if you need more than the default.
The dotnet ef tool and your first migration
Two installs, then three commands create the database. The dotnet ef tool comes as a global tool, and the Design package is required for any project that generates migrations:
dotnet tool install --global dotnet-ef
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet ef migrations add InitialCreate
dotnet ef database update
Expected output, trimmed:
Build started...
Build succeeded.
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (12ms) CREATE TABLE "Projects" (...)
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (7ms) CREATE TABLE "Tasks" (...)
Done.
The first command creates a Migrations folder with a C# snapshot of the model plus an InitialCreate migration (the CREATE TABLE statements you see executed). The second applies it to the database. This is the only migration this page needs: creating the database. Renaming columns, adding constraints, scripting deployments, and what happens when several instances run migrations at startup are the deep track. The EF Core migrations guide owns all of it. The dotnet ef CLI reference lists every command if you want the full surface now.
Conventions vs the fluent API
EF Core reads your model before you configure anything. The conventions do most of the work silently. A property named Id (or ClassNameId) becomes the primary key, ProjectId on TaskItem becomes a foreign key to Project. A List<TaskItem> on Project becomes the other side of the relationship. When a convention guesses wrong or you need something it cannot guess, two configuration styles exist:
| Aspect | Data annotations | Fluent API (OnModelCreating) |
|---|---|---|
| Where it lives | On the entity ([Required], [MaxLength]) |
In the context, per entity |
| Keeps entities clean? | No: persistence attributes on domain classes | Yes: mapping stays in the context |
| Covers | Common simple cases | Everything: indexes, keys, relationships, seeding, table splitting |
| When to prefer | Tiny models, quick wins | Anything the team will maintain |
The fluent API wins for teams because the mapping has one home: the context. Someone reviewing schema intent reads OnModelCreating top to bottom instead of hunting attributes across entity files.
Querying and saving: the two verbs you use all day
Reading is LINQ over a DbSet, translated to SQL and executed asynchronously; writing is tracked changes plus SaveChangesAsync:
public async Task<List<TaskItem>> GetOpenTasksAsync(Guid projectId, CancellationToken ct)
{
return await context.Tasks
.Where(t => t.ProjectId == projectId && !t.Done)
.OrderBy(t => t.CreatedAtUtc)
.ToListAsync(ct);
}
public async Task CreateTaskAsync(TaskItem task, CancellationToken ct)
{
context.Tasks.Add(task);
await context.SaveChangesAsync(ct);
}
Notice what is absent: SQL strings, result mapping, and any connection handling. Notice what is present: the CancellationToken in every call, honoring the async discipline from the guide on async and await in C#. This page stops at the basics. The performance-critical half of querying, tracking behavior, projections, includes, and finding the N+1 problem in your logs, is the EF Core query efficiency guide.
How real systems do this
- Context per request, contexts pooled.
AddDbContextgives each request its own context. High-throughput services useAddDbContextPool, which reuses context instances to reduce allocations, with identical code. - Seed data through the model.
HasDatainOnModelCreatingputs reference data (default projects, statuses) into migrations, so every environment, including tests, gets the same baseline. - The model lives in the repository, not the controller. Controllers and services see repositories or query objects. The context, LINQ, and lifetime rules stay in the data layer so they can evolve without touching the API surface.
A mistake I have seen in production is a team wiring the connection string into a committed appsettings file because “it’s only the dev database”. The dev database still held a copy of production-shaped data. The committed string was the same one a contractor’s environment used for a copy of that data. Moving to user-secrets locally and environment variables in production took an afternoon. What I took from it: connection strings are code secrets, whatever database they point at, and the habit of treating them correctly is formed on the first day.
Decision framework: EF Core, Dapper, or raw SQL
- Is the workload CRUD plus moderate queries? EF Core is the default: typed queries, change tracking, and migrations do most of the work.
- Do you need the last drop of performance on a specific query? EF Core supports raw SQL for that one spot. Keep the rest typed. Reach for Dapper project-wide only when nearly every query is hand-tuned.
- Who owns the schema? You own it: code-first. A DBA team owns it: database-first scaffolding, and migrations become a negotiation rather than a command.
- Are you bulk-inserting large sets? For one-off loads, use the database’s own tooling. For repeated bulk writes, measure EF Core’s bulk APIs against raw SQL before committing to either.
- Annotations or fluent? Team-maintained models: fluent in the context. A two-entity weekend project: annotations are fine.
When NOT to use EF Core
- Heavy analytical reporting. Multi-table aggregates over millions of rows belong to SQL tuned where it runs. Map the result with a small type and leave the ORM out of the hot path.
- A database you do not own. Scaffolding from a vendor schema works, but every migration becomes someone else’s problem. Consume it read-only or through views if the schema is theirs.
- Trivial, static data. A list of countries does not need a context, a provider, or a migration. Load a static resource once and skip the database entirely.
Common mistakes
- Querying before the migration runs. The classic first-run failure is
SqliteException: no such table: Tasks;dotnet ef database update(orEnsureCreatedfor throwaway prototypes only) must run first. - Blocking on async data calls.
context.SaveChangesAsync().Resultstarves the request thread pool under load, the exact failure mode in the async guide; await it. - Connection strings in committed files. Even development databases leak data; user-secrets locally, environment variables or key vaults in deployment.
- One context shared across requests. A DbContext is not thread-safe. The scoped lifetime from
AddDbContextexists precisely to keep one context per request. - Putting LINQ queries in controllers. The data layer owns queries so lifetimes, tracking choices, and query tuning have one home. Controllers stay contracts.
- Recreating the database in production to “fix” migrations.
EnsureCreatedand dropping the database are development moves. Production schema changes go through migrations, covered in the dedicated guide.
Key takeaways
- Code-first means the C# model is the truth and the schema follows: entities, one DbContext, migrations in version control.
- EF Core reads conventions (Id keys, foreign keys, navigations); the fluent API in
OnModelCreatingcovers everything conventions cannot guess. - The provider is one NuGet package plus one connection string: SQLite for zero-setup development, Npgsql or SqlServer for production servers.
dotnet tool install --global dotnet-efplus the Design package enablemigrations addanddatabase update; deeper schema management is its own guide.- Queries are LINQ, executed asynchronously with a cancellation token; writes are tracked changes plus
SaveChangesAsync. - EF Core is the default for services. Raw SQL, Dapper, or the database’s own tooling win for hand-tuned reports, bulk loads, and schemas you do not own.
FAQ
What is code-first in Entity Framework Core?
A modeling style where C# classes are the source of truth and the database schema is derived from them. You define entities, EF Core maps them through a DbContext, and migrations apply the model to a database as versioned steps. The opposite, database-first, scaffolds C# classes from an existing schema instead.
How do I set up EF Core with SQLite or PostgreSQL?
Add the provider package, Microsoft.EntityFrameworkCore.Sqlite for SQLite or Npgsql.EntityFrameworkCore.PostgreSQL for PostgreSQL. Then register the context with AddDbContext and UseSqlite or UseNpgsql plus a connection string from configuration. All other code, entities, queries, and migrations, stays provider-independent.
What is a DbContext in EF Core?
The class that maps your entity classes to a database: it holds a DbSet per entity, tracks changes to loaded objects, translates LINQ into SQL, and writes those changes on SaveChangesAsync. A context is scoped to a unit of work, one per request in a web app. This is because it is not thread-safe and is designed to be short-lived.
How do I create the database from my model?
Install the dotnet-ef global tool and the Design package, run dotnet ef migrations add InitialCreate to generate the first migration, then dotnet ef database update to apply it. The migrations guide in this series covers everything beyond that first create: altering columns, scripting, and deploying safely.
Should I use code-first or database-first?
Use code-first when you own the schema and want changes to travel with the code in pull requests. Use database-first scaffolding when the schema is owned by someone else, a DBA team or a vendor system. Your code needs to follow it. Both produce the same runtime: the difference is who owns the direction of change.
The schema is code
Code-first makes the database a consequence of the source: your entities compile, your schema follows, and every schema change is a reviewable diff instead of a script nobody remembers running. Get the setup right once (entities, one context, a provider, the first migration) and the deep tracks build directly on it: relationships next. Then migrations in production, then queries that stay fast under load. Start the next step with configuring EF Core relationships, and every guide in the series lives on the .NET series index. The rule of thumb: if the schema matters, it lives in code, migrates like code, and gets reviewed like code.
Last updated on 6 November 2026