Data access with EF Core

ASP.NET Core · Practical · 15 min

EF Core is the default way .NET services talk to relational databases.

Entity Framework Core is .NET's object-relational mapper. You describe the model as classes, and EF Core translates LINQ queries to SQL and tracks changes to write back.

  • Install a provider package, e.g. Npgsql.EntityFrameworkCore.PostgreSQL or Microsoft.EntityFrameworkCore.Sqlite.
  • Create a DbContext with a DbSet<T> per table.
  • Register it: builder.Services.AddDbContext<AppDb>(o => o.UseNpgsql(connString)). Contexts are scoped: one per request, not thread-safe.

Migrations evolve the schema: dotnet ef migrations add Initial generates C# code from model changes; dotnet ef database update applies it (in production, prefer generating an idempotent SQL script with dotnet ef migrations script --idempotent).

Querying uses LINQ on IQueryable, translated to SQL when you materialize with ToListAsync, FirstOrDefaultAsync, CountAsync. Write filters and projections before materializing so they run in the database. Select into DTOs to fetch only needed columns. AsNoTracking() speeds up read-only queries.

Saving: add, modify or remove tracked entities, then await db.SaveChangesAsync(), which runs all changes in one transaction. For bulk updates without loading entities use ExecuteUpdateAsync / ExecuteDeleteAsync.

Load related data explicitly with Include(o => o.Items); lazy loading is off by default, which avoids hidden N+1 queries.

Example

// dotnet add package Microsoft.EntityFrameworkCore.Sqlite
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDb>(o => o.UseSqlite("Data Source=app.db"));
var app = builder.Build();
app.MapGet("/todos", async (AppDb db) =>
    await db.Todos.AsNoTracking()
        .Where(t => !t.Done).OrderBy(t => t.Id)
        .Select(t => new { t.Id, t.Title })      // only needed columns
        .ToListAsync());
app.MapPost("/todos", async (AppDb db, Todo todo) =>
{
    db.Todos.Add(todo);
    await db.SaveChangesAsync();                 // INSERT, Id filled in
    return TypedResults.Created($"/todos/{todo.Id}", todo);
});
app.MapPost("/todos/complete-all", async (AppDb db) =>   // one UPDATE statement
    await db.Todos.ExecuteUpdateAsync(s => s.SetProperty(t => t.Done, true)));
app.Run();

public class Todo { public int Id { get; set; } public string Title { get; set; } = ""; public bool Done { get; set; } }
public class AppDb(DbContextOptions<AppDb> options) : DbContext(options) { public DbSet<Todo> Todos => Set<Todo>(); }

Remember

  • DbContext + DbSet<T> model the database; register it scoped with AddDbContext
  • Migrations generate and apply schema changes
  • Filter and project before ToListAsync so work happens in SQL
  • SaveChangesAsync writes tracked changes in one transaction

Watch out for

  • Calling ToList() early and filtering in memory, loading whole tables.

Check yourself

  1. Why must a DbContext not be registered as a singleton?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: learn.microsoft.com/en-us/ef/core/

Builds on

Unlocks

Nothing builds on this yet.