I remember when I was just starting to use Entity Framework (EF) seriously in one of my previous projects, a financial settlement system, the biggest hurdle for me was not the queries or migrations. It was understanding how everything in EF actually flows under the hood.
Two key classes kept popping up in every tutorial, in every example, and even in error messages: DbContext and DbSet.
At first, they felt like magic. But over time, as I built APIs and background jobs with EF, I came to appreciate that DbContext and DbSet are really the core foundation of EF.
So in this post, I’ll walk you through my personal journey of understanding them, what they really are, how they work together, and how you can use them effectively in your .NET 9 (and up) projects.
We’ll also look at sample code snippets from actual project scenarios and some tips I learned the hard way. By the end of this, I hope you’ll feel more confident working with EF at a deeper level.
What is DbContext?
Think of DbContext as the brain of EF Core. When I explain this to junior devs in our team, I usually compare it to a database session manager. Every time you interact with your database such as reading, inserting, updating, deleting you go through DbContext.
It handles three big things:
- Keeping track of changes you make to your objects (also called change tracking).
- Sending the right SQL queries to your database when you call SaveChanges() or SaveChangesAsync().
- Managing connections and transactions behind the scenes.
In my settlement system project, I configured a SettlementDbContext that knew about all the tables like MerchantSettlement, BankInfo, etc. This context was created and disposed per request (since we used AddScoped in dependency injection).
Here’s a simple example:
public class SettlementDbContext : DbContext
{
public SettlementDbContext(DbContextOptions<SettlementDbContext> options)
: base(options) { }
public DbSet<MerchantSettlement> MerchantSettlements { get; set; }
public DbSet<BankInfo> BankInfos { get; set; }
}
In this example:
- SettlementDbContext extends DbContext.
- It exposes two properties of type DbSet, one for each entity.
Every time you inject SettlementDbContext into your service or controller, EF gives you a fresh instance that you can use to track and persist changes.
What is DbSet?
If DbContext is the brain, DbSet is the gateway to a specific table (or view). It represents a collection of entities of a particular type, allowing you to:
- Query records (using LINQ)
- Add, update, or remove records.
You don’t usually create a DbSet instance yourself, you define it as a property in your DbContext, and EF wires it up for you.
Here’s what it looks like in practice:
public DbSet<MerchantSettlement> MerchantSettlements { get; set; }
Now in your service you can write code like:
var pendingSettlements = await _dbContext.MerchantSettlements
.Where(ms => ms.Status == "Pending")
.ToListAsync();
or add a new record:
_dbContext.MerchantSettlements.Add(new MerchantSettlement { … });
await _dbContext.SaveChangesAsync();
So DbSet is your direct entry point to the data in a specific table.
How Do They Work Together?
Here’s how I like to visualize it:
- You create a DbContext (or let DI create it for you).
- Inside the context, you have DbSet properties for each table/entity you care about.
- You use those DbSets to perform operations, and when you’re done, you call SaveChanges() (or SaveChangesAsync()), and the context executes all the pending changes as a transaction.
Here’s a flow diagram:

In my project, for example, we had to update hundreds of settlements’ statuses to “Settled” after processing them. We simply queried the DbSet, changed the properties in a loop, then called SaveChangesAsync() once and EF generated the correct batch SQL statements.
Sample Use Case: Processing Settlements
Let’s say you have a service that marks settlements as completed. Here’s how it could look:
public class SettlementService
{
private readonly SettlementDbContext _dbContext;
public SettlementService(SettlementDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task MarkAsCompletedAsync(int settlementId)
{
var settlement = await _dbContext.MerchantSettlements
.FirstOrDefaultAsync(s => s.Id == settlementId);
if (settlement == null) throw new Exception("Settlement not found");
settlement.Status = "Settled";
settlement.CompletedAt = DateTime.UtcNow;
await _dbContext.SaveChangesAsync();
}
}
Notice:
- We queried via DbSet.
- EF tracked the changes.
- SaveChangesAsync() persisted them.
Tips and Gotchas
From my experience, here are a few things to keep in mind:
- Always dispose of your DbContext properly (use dependency injection and scoped lifetime).
- DbContext is not thread-safe. Don’t share it across threads.
- Keep the lifetime of a DbContext short, one per request/unit of work.
- Avoid loading too many records at once (ToListAsync() on millions of rows = memory crash). Use pagination.
- Use .AsNoTracking() for read-only queries to improve performance.
Example:
var settlements = await _dbContext.MerchantSettlements
.AsNoTracking()
.Where(s => s.Status == "Settled")
.ToListAsync();
Why It Matters
Understanding DbContext and DbSet made a big difference for me when debugging issues. I could finally understand why some changes weren’t saved or why EF was tracking too many entities in memory.
If you really get how EF works at this level, you’ll write cleaner, more efficient data access code.
Sources and References
I always like to credit where I learned things from, even if over the years they got mixed with my own experience:
- Official Microsoft Docs: https://learn.microsoft.com/ef/core
- Personal experience from building financial settlement and HRIS systems
- Community tutorials from Stack Overflow, blogs, and conferences
This post is based on my own understanding and the patterns I used successfully in my projects. Your experience may differ and that’s okay. Try it out and adjust to your needs.