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.PostgreSQLorMicrosoft.EntityFrameworkCore.Sqlite. - Create a
DbContextwith aDbSet<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.