DbContext

Look up what a DbContext owns, how it is constructed and registered, why it is not thread-safe, and how long one instance should live.

Signature
public class DbContext : IDisposable, IAsyncDisposable
  public DbContext(DbContextOptions options)
  protected DbContext()
  • Added in .NET EF Core 1.0

Parameters

ParameterTypeDescription
$optionsDbContextOptions (usually DbContextOptions<TContext>)Provider, connection string, interceptors and behaviours, built by AddDbContext or a DbContextOptionsBuilder. A context used with AddDbContextPool must take only DbContextOptions<TContext>.

Return value

n/aA unit of work: one session with the database that tracks the entities it loaded or was given, and writes their changes in SaveChangesAsync.

Errors

InvalidOperationException when a second operation starts on the same instance before the first finished; ObjectDisposedException when used after disposal (often a context captured by a singleton or a fire-and-forget task).

Changelog

  • PHP EF Core 2.0AddDbContextPool reuses context instances between requests.
  • PHP EF Core 5.0IDbContextFactory<TContext> with AddDbContextFactory and AddPooledDbContextFactory; SavingChanges, SavedChanges and SaveChangesFailed events.
  • PHP EF Core 6.0ConfigureConventions for model-wide type defaults; default pool size raised to 1024.
  • PHP EF Core 9.0UseSeeding and UseAsyncSeeding options for data seeding.

DbContext is the session object of EF Core. One instance owns a change tracker (an identity map of every entity it loaded or was handed, plus a snapshot of their original values), a reference to the model (built once per context type on first use and cached for the life of the process), and a database connection that it opens for each operation and closes afterwards unless a transaction or an explicit OpenConnectionAsync keeps it open. Everything else (DbSet properties, Database, ChangeTracker, Entry) is a view onto those three.

Members you actually call

MemberWhat it does
Set<T>() / DbSet propertiesQuery root and entry point for Add, Remove, Find.
SaveChangesAsync(ct)Detects changes, writes them atomically, returns the number of state entries written.
Entry(entity)State, original and current values, explicit loading of one entity.
ChangeTrackerEntries(), Clear(), QueryTrackingBehavior.
DatabaseTransactions, raw SQL, execution strategy, migrations.
OnModelCreating / ConfigureConventionsModel configuration, run once per context type, not per instance.

Lifetime: one per unit of work

AddDbContext<T> registers the context as scoped, so in ASP.NET Core each request gets its own instance, disposed when the request ends. AddDbContextPool<T> keeps up to 1024 instances by default and resets their state on return, saving the allocation and set-up cost on hot paths. A pooled context must have a constructor that takes only DbContextOptions<T>; per-request state (a tenant id) has to be set after leasing, not injected. Outside a request (a BackgroundService, a message handler, a parallel job) inject IDbContextFactory<T> and create a short-lived context per unit of work.

Prefer AddDbContextPool for a busy API whose context needs no injected services; use AddDbContext when the constructor needs scoped services such as a tenant accessor.

It is not thread-safe

A context runs one operation at a time on one connection. Starting a second query before the first completes, typically Task.WhenAll(db.Orders.CountAsync(), db.Payments.SumAsync(...)), throws InvalidOperationException with "A second operation was started on this context instance". Await each call, or give each parallel branch its own context from the factory.

Production failure modes

  • Captive context. A singleton that takes the context in its constructor keeps one instance for the life of the process: concurrency exceptions under load, stale data, and a tracker that grows without limit. Scope validation in Development catches the registration; code review has to catch the manual cases.
  • Long-lived context. A batch job that reuses one context for 200,000 rows slows down on every SaveChangesAsync because change detection scans every tracked entity. Use a new context (or ChangeTracker.Clear()) per batch.
  • Fire-and-forget. _ = SendReceiptAsync(db) outlives the request and hits ObjectDisposedException after the scope is disposed.

Example

Example · csharp
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// Scoped: one ShopDb per request, returned to the pool when the request ends.
builder.Services.AddDbContextPool<ShopDb>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Shop"))
           .EnableDetailedErrors(builder.Environment.IsDevelopment()));

var app = builder.Build();

app.MapGet("/customers/{id:int}/orders", async (int id, ShopDb db, CancellationToken ct) =>
    await db.Orders
        .AsNoTracking()
        .Where(o => o.CustomerId == id)
        .OrderByDescending(o => o.PlacedAt)
        .Select(o => new OrderRow(o.Id, o.Status, o.Total))
        .ToListAsync(ct));

app.MapPost("/orders/{id:int}/cancel", async (int id, ShopDb db, CancellationToken ct) =>
{
    var order = await db.Orders.FindAsync([id], ct);
    if (order is null) return Results.NotFound();
    if (order.Status == OrderStatus.Shipped) return Results.Conflict();

    order.Status = OrderStatus.Cancelled;   // tracked, so SaveChanges sees the change
    await db.SaveChangesAsync(ct);          // UPDATE "Orders" SET "Status" = @p0 WHERE "Id" = @p1
    return Results.NoContent();
});

app.Run();

public enum OrderStatus { Pending, Paid, Shipped, Cancelled }

public class Order
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    public OrderStatus Status { get; set; }
    public decimal Total { get; set; }
    public DateTimeOffset PlacedAt { get; set; }
}

public record OrderRow(int Id, OrderStatus Status, decimal Total);

// Pooling requires exactly this constructor: DbContextOptions<ShopDb> and nothing else.
public class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();

    // Runs once per process for this context type; the built model is cached.
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Order>(b =>
        {
            b.Property(o => o.Total).HasPrecision(12, 2);
            b.Property(o => o.Status).HasConversion<string>().HasMaxLength(16);
            b.HasIndex(o => new { o.CustomerId, o.PlacedAt });
        });
    }
}

When to use it

  • Registering ShopDb with AddDbContextPool so a high-traffic orders API reuses context instances instead of building one per request
  • Cancelling unpaid orders every five minutes from a BackgroundService with a fresh context per pass from IDbContextFactory
  • Diagnosing 'A second operation was started on this context instance' after someone added Task.WhenAll to a dashboard endpoint

Frequently asked questions

Should I use AddDbContext or AddDbContextPool?

AddDbContextPool when the context constructor takes only DbContextOptions<T> and the API is busy: it saves per-request set-up. AddDbContext when the context needs injected scoped services, such as a tenant accessor, because a pooled instance outlives any one scope.

Why can't I run two queries at once on the same context?

A context wraps one connection and one change tracker and is not thread-safe. EF Core detects overlapping operations and throws InvalidOperationException. Await each query, or use a separate context per concurrent branch.

Is OnModelCreating called for every request?

No. The model is built the first time a context type is used and cached for the process. That is why OnModelCreating must not depend on per-request values; query filters read context properties at query time instead.

More examples

A context per pass in a BackgroundService

A hosted service is a singleton, so it cannot take a scoped <code>ShopDb</code>. <code>IDbContextFactory&lt;ShopDb&gt;</code> is a singleton that hands out contexts the caller owns and must dispose. <code>AddPooledDbContextFactory</code> reuses instances in the same way <code>AddDbContextPool</code> does. The try/catch matters: an unhandled exception in <code>ExecuteAsync</code> stops the whole host, so one transient connection error would end the worker.

Example · csharp
using Microsoft.EntityFrameworkCore;

var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddPooledDbContextFactory<ShopDb>(o =>
    o.UseNpgsql(builder.Configuration.GetConnectionString("Shop")));
builder.Services.AddHostedService<UnpaidOrderSweeper>();
builder.Build().Run();

public sealed class UnpaidOrderSweeper(
    IDbContextFactory<ShopDb> contexts, ILogger<UnpaidOrderSweeper> log) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            try
            {
                // New context each pass: the tracker never accumulates entities across passes.
                await using var db = await contexts.CreateDbContextAsync(stoppingToken);
                var cutoff = DateTimeOffset.UtcNow.AddHours(-24);
                var cancelled = await db.Orders
                    .Where(o => o.Status == "Pending" && o.PlacedAt < cutoff)
                    .ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, "Cancelled"), stoppingToken);
                log.LogInformation("Cancelled {Count} unpaid orders older than 24 hours", cancelled);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                // An unhandled exception would stop the host; log it and try again on the next tick.
                log.LogError(ex, "Unpaid order sweep failed");
            }
        }
    }
}

public class Order
{
    public int Id { get; set; }
    public string Status { get; set; } = "Pending";
    public DateTimeOffset PlacedAt { get; set; }
}

public class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();
}

Pitfall: parallel queries on one context

Parallelism costs a pooled connection per branch, so it only pays off for slow, independent queries. For two quick counts, awaiting them one after the other on the request's own context is simpler and usually just as fast.

Example · csharp
using Microsoft.EntityFrameworkCore;

public sealed class DashboardQueries(IDbContextFactory<ShopDb> contexts)
{
    // Throws InvalidOperationException: a second operation started before the first completed.
    public async Task<DashboardTotals> Broken(CancellationToken ct)
    {
        await using var db = await contexts.CreateDbContextAsync(ct);
        var count = db.Orders.CountAsync(ct);
        var unpaid = db.Orders.CountAsync(o => !o.Paid, ct);
        await Task.WhenAll(count, unpaid);
        return new DashboardTotals(count.Result, unpaid.Result);
    }

    // Fix: one context, and one connection, per concurrent branch.
    public async Task<DashboardTotals> Parallel(CancellationToken ct)
    {
        async Task<int> Count(bool onlyUnpaid)
        {
            await using var db = await contexts.CreateDbContextAsync(ct);
            return onlyUnpaid ? await db.Orders.CountAsync(o => !o.Paid, ct) : await db.Orders.CountAsync(ct);
        }

        var results = await Task.WhenAll(Count(false), Count(true));
        return new DashboardTotals(results[0], results[1]);
    }
}

public record DashboardTotals(int Orders, int Unpaid);

public class Order
{
    public int Id { get; set; }
    public bool Paid { get; set; }
}

public class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();
}

Test: two contexts over one SQLite in-memory database

Reading back through the same context would return the tracked instance from the identity map and prove nothing about the database. Use a fresh context for the assertion. SQLite is fine for mapping smoke tests; provider-specific SQL needs a real PostgreSQL in Testcontainers.

Example · csharp
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Xunit;

public sealed class ShopDbTests : IAsyncLifetime
{
    // The in-memory database lives as long as this open connection.
    private readonly SqliteConnection _connection = new("Data Source=:memory:");

    private ShopDb NewContext() =>
        new(new DbContextOptionsBuilder<ShopDb>().UseSqlite(_connection).Options);

    public async ValueTask InitializeAsync()
    {
        await _connection.OpenAsync();
        await using var db = NewContext();
        await db.Database.EnsureCreatedAsync();
    }

    public async ValueTask DisposeAsync() => await _connection.DisposeAsync();

    [Fact]
    public async Task Saved_order_is_read_back_from_the_database()
    {
        var ct = TestContext.Current.CancellationToken;
        int id;
        await using (var write = NewContext())
        {
            var order = new Order { CustomerId = 42, Status = "Pending" };
            write.Orders.Add(order);
            await write.SaveChangesAsync(ct);
            id = order.Id;
        }

        // A second context has an empty tracker, so this really queries SQLite.
        await using var read = NewContext();
        var loaded = await read.Orders.SingleAsync(o => o.Id == id, ct);
        Assert.Equal(42, loaded.CustomerId);
    }
}

public class Order
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    public string Status { get; set; } = "";
}

public class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();
}

Discussion

  • Be the first to comment on this lesson.