Articles › EF Core & SQL
Entity Framework Core for Beginners: Code-First, DbContext and Migrations
Learn EF Core the right way: DbContext, entities, Fluent API configuration, migrations, CRUD queries, change tracking and the habits that keep your data layer fast.
Entity Framework Core (EF Core) is the official ORM for .NET. It lets you work with a relational database using C# classes and LINQ instead of hand-written SQL, while still generating efficient queries. In this guide you'll learn the Code-First workflow end to end — and the concepts that separate "it works" from "it works well".
What is an ORM and why use one?
An Object-Relational Mapper translates between two worlds: objects in memory and rows in tables. Without one, you write SQL strings, open connections, read DataReaders and map columns to properties by hand. EF Core automates this:
- Queries written in LINQ are translated to SQL.
- Results are materialized into your C# objects.
- Changes to those objects are detected and saved with
SaveChanges(). - Schema changes are versioned with migrations.
You still need to understand SQL — EF Core doesn't remove that need — but you write far less boilerplate.
Install the packages
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet tool install --global dotnet-ef
Swap SqlServer for Sqlite, Npgsql.EntityFrameworkCore.PostgreSQL or Pomelo.EntityFrameworkCore.MySql to target another database. Your entities and queries stay the same.
Define your entities
Entities are plain C# classes. EF Core uses conventions to understand them: a property named Id (or BlogId) becomes the primary key, string becomes nvarchar, and so on.
public class Blog
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public List<Post> Posts { get; set; } = [];
}
public class Post
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
public bool IsPublished { get; set; }
public int BlogId { get; set; } // foreign key
public Blog Blog { get; set; } = null!; // navigation property
}
Create the DbContext
The DbContext is your session with the database. It exposes a DbSet<T> for each table and holds the configuration of your model.
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<Blog> Blogs => Set<Blog>();
public DbSet<Post> Posts => Set<Post>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
}
Configure with the Fluent API
Conventions cover the basics; the Fluent API gives you full control. Keeping each entity's configuration in its own class keeps the DbContext clean:
public class PostConfiguration : IEntityTypeConfiguration<Post>
{
public void Configure(EntityTypeBuilder<Post> builder)
{
builder.Property(p => p.Title).HasMaxLength(200).IsRequired();
builder.HasIndex(p => new { p.BlogId, p.IsPublished });
builder.HasOne(p => p.Blog)
.WithMany(b => b.Posts)
.HasForeignKey(p => p.BlogId)
.OnDelete(DeleteBehavior.Cascade);
}
}
Tip: Always set
HasMaxLengthon string columns you search or index. Without it, SQL Server usesnvarchar(max), which can't be indexed efficiently.
Register it in ASP.NET Core
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
AddDbContext registers the context with a scoped lifetime: one instance per HTTP request. That's exactly what you want — a DbContext is lightweight, not thread-safe, and should be short-lived.
Migrations: versioning your schema
A migration is a C# file describing how to move the database from one version of the model to the next.
dotnet ef migrations add InitialCreate
dotnet ef database update
Each time you change your entities, add a new migration with a descriptive name:
dotnet ef migrations add AddPostSlug
dotnet ef database update
Useful commands:
| Command | Purpose |
|---|---|
dotnet ef migrations list |
Show all migrations and which are applied |
dotnet ef migrations remove |
Delete the last (unapplied) migration |
dotnet ef database update AddPostSlug |
Move to a specific migration (also backwards) |
dotnet ef migrations script --idempotent |
Generate a SQL script for production |
Warning: In production, prefer reviewing and running an idempotent SQL script (or a migration bundle) as part of your deployment, instead of calling
Database.Migrate()from several app instances at startup.
CRUD with EF Core
Create
var blog = new Blog { Title = "Engineering notes" };
blog.Posts.Add(new Post { Title = "Hello EF Core", Body = "..." });
db.Blogs.Add(blog);
await db.SaveChangesAsync(); // inserts the blog and the post in one transaction
Read
var published = await db.Posts
.AsNoTracking()
.Where(p => p.IsPublished && p.Blog.Title.StartsWith("Eng"))
.OrderByDescending(p => p.Id)
.Select(p => new { p.Id, p.Title, Blog = p.Blog.Title })
.ToListAsync();
LINQ isn't executed until you materialize it with ToListAsync, FirstOrDefaultAsync, CountAsync and so on. Until then you're just building an expression that EF Core translates to a single SQL query.
Update
var post = await db.Posts.FirstAsync(p => p.Id == id);
post.Title = "Updated title";
await db.SaveChangesAsync(); // UPDATE only the changed column
For bulk updates, skip loading entities entirely:
await db.Posts
.Where(p => p.BlogId == blogId)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.IsPublished, true));
Delete
await db.Posts.Where(p => p.Id == id).ExecuteDeleteAsync();
Understanding change tracking
When you load entities normally, the DbContext keeps a snapshot of them. On SaveChanges() it compares current values with the snapshot and generates UPDATE statements for what changed. Each entity has a state:
Added→ will be insertedModified→ will be updatedDeleted→ will be deletedUnchanged→ nothing to doDetached→ not tracked
Tracking costs memory and CPU. For read-only queries — most of a typical API — use AsNoTracking().
Loading related data
// Eager loading: one query with a JOIN
var blogs = await db.Blogs.Include(b => b.Posts).ToListAsync();
// Projection: usually the best option — load only what you show
var summaries = await db.Blogs
.Select(b => new { b.Title, PostCount = b.Posts.Count })
.ToListAsync();
Avoid lazy loading in web APIs: it silently issues one query per navigation access and is the classic cause of the N+1 problem.
Seeding data
modelBuilder.Entity<Blog>().HasData(
new Blog { Id = 1, Title = "Welcome", CreatedAt = new DateTime(2025, 1, 1) });
HasData is great for fixed reference data (countries, statuses). For larger or environment-specific data, seed from code at startup.
Best practices summary
- Keep the
DbContextscoped and short-lived. - Use
AsNoTracking()andSelectprojections for reads. - Configure lengths, indexes and relationships explicitly.
- Give migrations meaningful names and review the generated code.
- Use
ExecuteUpdate/ExecuteDeletefor bulk operations. - Log the generated SQL in development (
options.LogTo(Console.WriteLine)) and read it.
Summary
EF Core lets you model your database with C# classes, query it with LINQ and evolve it safely with migrations. Learn how DbContext lifetime, change tracking and query translation work, and you'll write data access code that is both productive and fast.
FAQ
Is EF Core slower than raw SQL or Dapper?
For typical queries the difference is small, especially with AsNoTracking and projections. For extremely hot paths you can mix in raw SQL (FromSql) or Dapper — they work fine side by side.
Code-First or Database-First?
Code-First is ideal for new projects: the model lives in source control and migrations document every change. For an existing database, use dotnet ef dbcontext scaffold to generate entities.
Should I call Database.Migrate() at startup?
It's convenient for small apps and development. For multi-instance production deployments, apply migrations as a controlled deployment step.