Updated Jun 23, 2026

What EF Core Is & the DbContext

Here's the situation. You're building a .NET app, and somewhere it needs to read and write rows in a SQL database. You could hand-write every INSERT, SELECT, and UPDATE, opening a connection, building a command, and reading columns back one at a time. People do — and it works. It's also a lot of repetitive plumbing, and the day a column name changes, you're chasing it through every query that touched it.

EF Core — Entity Framework Core — is what most ASP.NET Core apps reach for instead. It's .NET's flagship ORM (object-relational mapper), and the deal it offers is direct: you describe your tables as plain C# classes, and EF Core writes the SQL for create, read, update, delete, relationships, and schema migrations. Fewer typos in column names, less boilerplate, and the shape of your data lives in one place — the class.

📝 This phase teaches the library. It assumes you know C# (classes, generics, LINQ basics, async/awaitC# From Zero) and the basics of databases (tables, rows, keys — What a Database Is). If you've used an ORM in another language, the core ideas transfer straight across — see GORM From Zero for the same concepts in Go.

The honest cost

Every convenience comes with a bill, and it's only fair to name EF Core's up front. When a library writes your SQL for you, you stop seeing your SQL — and that's exactly where ORMs earn their bad reputation. One innocent-looking method call can fire off a query you'd never have written by hand, and if you're not watching, you find out in production when a page is mysteriously slow.

⚠️ The cure isn't avoiding EF Core — it's watching the SQL it generates. Good engineers like to know what's really happening, and EF Core rewards that: it can log every statement it runs. Turn that on while you learn (we'll do it in a minute), and the ORM stops being a black box. You'll see the INSERT behind an Add, the SELECT behind a query — and the moment a call does something expensive.

The mental model

Before any setup, hold this picture. It's the whole guide in one line:

A DbContext is a change-tracking session, a DbSet<T> is a table, and LINQ becomes SQL.

Three pieces:

  • The DbContext = your session with the database. You create one, do some work through it, and dispose it. While it's alive it tracks the changes you make to the objects it hands you, and pushes them all to the database when you call SaveChanges.
  • A DbSet<T> = a table. Your context exposes one DbSet property per table — DbSet<Blog> is the Blogs table. You add to it, and you query through it.
  • LINQ becomes SQL. You write queries in C#'s built-in query language (LINQ), and EF Core translates them into real SELECT ... WHERE ... statements. (Querying is Phase 4 — for now, just know that's the flow.)

💡 And here's the freedom: EF Core is a SQL generator, not a cage. When the high-level API gets awkward — a gnarly report, a bulk update — you drop straight to raw SQL with FromSql(...) or ExecuteSql(...), against the same connection. You never lose access to the database underneath.

Installing EF Core

EF Core is the core library plus a provider for your specific database. We'll use SQLite — it needs zero setup (the database is just a file), so you can run everything in this guide without standing up a server.

From inside a console project (dotnet new console -o blog if you're starting fresh), pull in the SQLite provider:

dotnet add package Microsoft.EntityFrameworkCore.Sqlite

What just happened: dotnet add package downloaded the SQLite provider and added it to your .csproj. That one package brings EF Core's core along with it as a dependency, plus the adapter that teaches EF Core to speak SQLite specifically. If you later move to PostgreSQL or SQL Server, you swap the provider (Npgsql.EntityFrameworkCore.PostgreSQL or Microsoft.EntityFrameworkCore.SqlServer) and very little else changes.

Defining a DbContext and an entity

Now the heart of it. You write two kinds of class: a DbContext subclass that names your tables and points at the database, and an entity class for each table — a plain class whose properties become columns:

using Microsoft.EntityFrameworkCore;

public class BlogContext : DbContext
{
    public DbSet<Blog> Blogs => Set<Blog>();
    public DbSet<Post> Posts => Set<Post>();

    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options.UseSqlite("Data Source=blog.db");
}

public class Blog
{
    public int Id { get; set; }
    public string Url { get; set; } = "";
}

What just happened: BlogContext derives from DbContext, which is what makes it a database session. Its two DbSet<T> properties declare the tables — Blogs and Posts — and => Set<Blog>() is the standard way to wire each one up. The OnConfiguring override is where the context learns which database to talk to: UseSqlite("Data Source=blog.db") says "use the SQLite provider, pointed at a file called blog.db" (created automatically if it doesn't exist). Below that, Blog is an entity — an ordinary C# class. Its Id and Url properties will become columns; EF Core treats a property named Id as the primary key by convention. (We'll define Post and turn these into real tables in Phase 2.)

📝 In an ASP.NET Core app you usually don't write OnConfiguring. Instead you register the context with dependency injection via AddDbContext<BlogContext>(...), and the framework hands a fresh, correctly-scoped context to each request — see ASP.NET Core From Zero. The mental model is identical; only the wiring differs.

Opening, saving, and disposing

With the classes in place, using the context is three moves — create it, change something, save:

using var ctx = new BlogContext();

ctx.Blogs.Add(new Blog { Url = "https://example.com" });
ctx.SaveChanges();

Run it with dotnet run.

What just happened: new BlogContext() opened a session. ctx.Blogs.Add(...) didn't touch the database yet — it told the context "start tracking this new Blog, I intend to insert it." Nothing is written until ctx.SaveChanges(), which looks at everything the context is tracking, generates the SQL, and runs it in a single batch (here, one INSERT). The using var is what makes this safe: DbContext is meant to be short-lived, and using disposes it — releasing the connection — the moment the block ends. Create one, do a unit of work, let it go. (SaveChanges has an async twin, SaveChangesAsync, which is what you'd use in a web app.)

Turn on the SQL log

Remember the honest cost — not seeing your SQL? Here's the fix, and it's the single best habit you can build while learning. Chain LogTo onto your provider setup and EF Core prints every statement it runs:

protected override void OnConfiguring(DbContextOptionsBuilder options)
    => options.UseSqlite("Data Source=blog.db")
              .LogTo(Console.WriteLine);

What just happened: the only change is .LogTo(Console.WriteLine), which hands EF Core a place to send its log lines — here, straight to the console. From now on, every query in this guide leaves a trail you can read. (In development you can also add .EnableSensitiveDataLogging() to see the actual parameter values in the log, not just @p0 placeholders — handy while learning, but keep it out of production, since it can print real data.)

Re-run the save with logging on, and the INSERT from a moment ago shows up looking roughly like this:

INSERT INTO "Blogs" ("Url")
VALUES (@p0);
SELECT "Id"
FROM "Blogs"
WHERE changes() = 1 AND "rowid" = last_insert_rowid();

What just happened: that's the literal SQL behind Add + SaveChanges. The first statement inserts the row; the second reads back the database-generated Id so EF Core can fill it into your Blog object in memory. You wrote two C# lines and here's exactly what they became — that one log block is the antidote to the "black box" problem. 💡 Keep this on the entire time you're learning. The instant a single call fires five queries, or runs a SELECT with no WHERE, you'll see it.

The running example: a blog

Rather than disconnected snippets, this whole guide builds one small, recognizable schema — a blog — and grows it phase by phase. You've already met two of its tables; here's the cast and how they relate:

flowchart LR
  Blog -- "has many" --> Post
  Post -- "tagged with many" --> Tag
  Tag -- "applied to many" --> Post

What just happened: the diagram lays out where we're headed. A Blog has many Posts — a one-to-many relationship. And a Post can carry many Tags while each Tag labels many posts — a many-to-many. Right now they're just boxes and arrows; in Phase 2 we turn these into real entity classes and use migrations to create the tables, then from there we create rows, query them with LINQ, watch change tracking batch our edits, and wire up these relationships.

For this phase the win is concrete: you can connect, you've saved a row, the SQL log is on, and you hold the mental model. That's the foundation everything else stands on.

Recap

  1. EF Core is .NET's flagship ORM — you describe tables as C# classes and it writes the SQL for CRUD, relationships, and migrations, so you skip the hand-written query boilerplate.
  2. The honest cost is invisible SQL. The cure is LogTo: turn it on while learning so you see the exact statement behind every call.
  3. The mental model: a DbContext is a change-tracking session, a DbSet<T> is a table, and LINQ becomes SQL — and you can always drop to raw SQL with FromSql/ExecuteSql.
  4. Install a provider (Microsoft.EntityFrameworkCore.Sqlite here), define a DbContext subclass with DbSet properties, and point it at the database in OnConfiguring with UseSqlite("Data Source=...").
  5. Add then SaveChanges: Add only starts tracking; nothing hits the database until SaveChanges batches and runs the SQL. Keep the context short-livedusing var ctx = new BlogContext();.
  6. In ASP.NET Core you register the context with AddDbContext and let DI hand one per request, instead of writing OnConfiguring.

Quick check

Three questions on the framing that has to stick before Phase 2:

[
  {
    "q": "In EF Core's mental model, what is a `DbContext`?",
    "choices": [
      "A change-tracking session with the database that exposes DbSets and pushes changes on SaveChanges",
      "A single row fetched from a table",
      "The SQLite file on disk",
      "A class that maps directly to exactly one table"
    ],
    "answer": 0,
    "explain": "A `DbContext` is your session with the database: it holds `DbSet<T>` properties (the tables), tracks the changes you make, and writes them all when you call `SaveChanges`. A class that maps to one table is an entity; a `DbSet<T>` represents the table."
  },
  {
    "q": "After `ctx.Blogs.Add(blog);`, when is the row actually written to the database?",
    "choices": [
      "When you call `ctx.SaveChanges()` — `Add` only starts tracking it",
      "Immediately, inside the `Add` call",
      "When the `DbContext` is garbage collected",
      "Only after you manually open a transaction"
    ],
    "answer": 0,
    "explain": "`Add` just tells the context to start tracking the new entity as something to insert. Nothing reaches the database until `SaveChanges`, which generates and runs the SQL (here an `INSERT`) in one batch."
  },
  {
    "q": "Why is `.LogTo(Console.WriteLine)` such a good habit while learning EF Core?",
    "choices": [
      "It prints the exact SQL behind every call, so the ORM stops being a black box",
      "It makes queries run faster by caching them",
      "It is required or EF Core refuses to connect",
      "It automatically rewrites slow queries for you"
    ],
    "answer": 0,
    "explain": "The honest cost of an ORM is that you stop seeing your SQL. `LogTo` prints every generated statement, so you immediately catch surprises — like one call firing several queries or a `SELECT` with no `WHERE`."
  }
]

Guide overview · Phase 2: Entity Models & Migrations →

Check your understanding

1. In EF Core's mental model, what is a `DbContext`?

2. After `ctx.Blogs.Add(blog);`, when is the row actually written to the database?

3. Why is `.LogTo(Console.WriteLine)` such a good habit while learning EF Core?

Was this page helpful?