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 HasMaxLength on string columns you search or index. Without it, SQL Server uses nvarchar(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 inserted
  • Modified → will be updated
  • Deleted → will be deleted
  • Unchanged → nothing to do
  • Detached → not tracked

Tracking costs memory and CPU. For read-only queries — most of a typical API — use AsNoTracking().

// 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

  1. Keep the DbContext scoped and short-lived.
  2. Use AsNoTracking() and Select projections for reads.
  3. Configure lengths, indexes and relationships explicitly.
  4. Give migrations meaningful names and review the generated code.
  5. Use ExecuteUpdate/ExecuteDelete for bulk operations.
  6. 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.