Skip to main content
SEMastery
Data Accessintermediate

DbContext Is Not Thread-Safe: Parallelizing EF Core Queries the Right Way

Learn why EF Core DbContext is not thread-safe and how to run parallel queries safely using IDbContextFactory in .NET 10. Beginner friendly.

12 min readUpdated February 16, 2026

DbContext Is Not Thread-Safe: Parallelizing EF Core Queries the Right Way

Picture a single notebook on a teacher's desk. One student walks up, writes their homework score, and walks away. Then the next student does the same. The notebook works perfectly, as long as only one person writes in it at a time.

Now imagine three students grab the same notebook at the exact same moment. One writes on a line, another erases it, a third flips the page. The notebook turns into a mess. Nobody can trust what is written there.

A DbContext in Entity Framework Core is exactly like that notebook. It is happy serving one job at a time. The moment two pieces of code try to use it together, things break. This article explains why that happens, and shows you the calm, correct way to run database queries side by side.

What "thread-safe" even means

A thread is like a worker. Your program can have many workers running at the same time. When two workers can safely share the same object without stepping on each other, we say that object is "thread-safe."

A DbContext is not thread-safe. It holds state. It tracks which rows you loaded, which ones you changed, and it manages one database connection. It was designed for a single unit of work. If two workers poke it together, the internal bookkeeping gets corrupted.

One DbContext is fine with one worker, but breaks with two at once

The official EF Core guidance says it plainly: EF Core does not support multiple parallel operations on the same context instance. This covers both running async queries in parallel and using the context from different threads.

The mistake that everyone makes first

Here is the code that looks innocent but is actually a trap. Imagine you want to load a customer's orders, their invoices, and their support tickets all at once to speed things up.

// DANGER: this shares ONE DbContext across three parallel tasks
public async Task<DashboardData> GetDashboardAsync(int customerId)
{
    var ordersTask = _dbContext.Orders
        .Where(o => o.CustomerId == customerId)
        .ToListAsync();
 
    var invoicesTask = _dbContext.Invoices
        .Where(i => i.CustomerId == customerId)
        .ToListAsync();
 
    var ticketsTask = _dbContext.Tickets
        .Where(t => t.CustomerId == customerId)
        .ToListAsync();
 
    // All three run on the SAME context at the same time
    await Task.WhenAll(ordersTask, invoicesTask, ticketsTask);
 
    return new DashboardData(
        ordersTask.Result,
        invoicesTask.Result,
        ticketsTask.Result);
}

Notice that we start all three tasks before awaiting any of them. That means all three are running on _dbContext together. EF Core notices this and throws an error like:

A second operation was started on this context instance before a previous operation completed.

The fix is not to "try again." The fix is to give each task its own context.

Why the shared-context code fails

Start 3 tasks
Same DbContext
Connection clash
Exception

Steps

1

Start 3 tasks

None awaited yet

2

Same DbContext

All share one instance

3

Connection clash

One connection, three users

4

Exception

EF Core stops you

Three queries collide on one connection

Rule one: await right away

Most of the time you do not need parallel queries at all. The simplest, safest habit is to await each async call immediately. Finish one query, then start the next.

// SAFE: one operation at a time on the shared context
public async Task<DashboardData> GetDashboardSequentialAsync(int customerId)
{
    var orders = await _dbContext.Orders
        .Where(o => o.CustomerId == customerId)
        .ToListAsync();
 
    var invoices = await _dbContext.Invoices
        .Where(i => i.CustomerId == customerId)
        .ToListAsync();
 
    var tickets = await _dbContext.Tickets
        .Where(t => t.CustomerId == customerId)
        .ToListAsync();
 
    return new DashboardData(orders, invoices, tickets);
}

This runs the three queries one after another. It is not parallel, but it is correct and easy to read. For a web request that only needs a few quick queries, this is usually fast enough. Reach for parallelism only when you have measured a real need.

Sequential queries: each finishes before the next begins

Rule two: one context per task

When you truly want queries to run at the same time, the golden rule is simple: one DbContext per parallel task. Each task gets its own notebook. No sharing, no collisions.

Since .NET 5, EF Core gives you a built-in tool for this exact job: IDbContextFactory<T>. Instead of injecting a single shared context, you inject a factory. The factory hands you fresh, lightweight context instances whenever you ask. Each new context has its own database connection and its own change tracker.

A factory gives each task its own DbContext and connection

Step 1: register the factory

In your Program.cs, register the factory instead of (or alongside) the normal scoped context.

builder.Services.AddDbContextFactory<AppDbContext>(options =>
    options.UseSqlServer(
        builder.Configuration.GetConnectionString("Default")));

AddDbContextFactory wires up IDbContextFactory<AppDbContext> for you. The connection string and provider are configured once, right here.

Step 2: use the factory in your service

Now inject the factory and create a fresh context for each parallel task. The key trick: each task is its own async method (or lambda) that owns its context from creation to disposal.

public class DashboardService
{
    private readonly IDbContextFactory<AppDbContext> _factory;
 
    public DashboardService(IDbContextFactory<AppDbContext> factory)
    {
        _factory = factory;
    }
 
    public async Task<DashboardData> GetDashboardAsync(int customerId)
    {
        // Each task creates and owns its own context
        var ordersTask = LoadOrdersAsync(customerId);
        var invoicesTask = LoadInvoicesAsync(customerId);
        var ticketsTask = LoadTicketsAsync(customerId);
 
        await Task.WhenAll(ordersTask, invoicesTask, ticketsTask);
 
        return new DashboardData(
            ordersTask.Result,
            invoicesTask.Result,
            ticketsTask.Result);
    }
 
    private async Task<List<Order>> LoadOrdersAsync(int customerId)
    {
        await using var ctx = await _factory.CreateDbContextAsync();
        return await ctx.Orders
            .Where(o => o.CustomerId == customerId)
            .ToListAsync();
    }
 
    private async Task<List<Invoice>> LoadInvoicesAsync(int customerId)
    {
        await using var ctx = await _factory.CreateDbContextAsync();
        return await ctx.Invoices
            .Where(i => i.CustomerId == customerId)
            .ToListAsync();
    }
 
    private async Task<List<Ticket>> LoadTicketsAsync(int customerId)
    {
        await using var ctx = await _factory.CreateDbContextAsync();
        return await ctx.Tickets
            .Where(t => t.CustomerId == customerId)
            .ToListAsync();
    }
}

Look closely at each Load... method. It says await using var ctx. That line creates a new context and promises to dispose it when the method ends. Three tasks, three contexts, three connections. They never touch each other, so EF Core stays happy.

The safe parallel pattern

Ask factory
New context
Run query
Dispose
Gather all

Steps

1

Ask factory

CreateDbContextAsync

2

New context

Own connection

3

Run query

Awaited inside task

4

Dispose

await using cleans up

5

Gather all

Task.WhenAll combines

Each task owns its context end to end

Sequential vs parallel: a side-by-side look

It helps to see the trade-offs laid out clearly. Neither approach is "always best." It depends on your situation.

ApproachContexts usedConnectionsWhen to use
Await one at a timeOne shared contextOneDefault choice, simple cases, few queries
Parallel with factoryOne per taskOne per taskSeveral independent, slow queries you must speed up
Shared context + parallelOne shared contextOneNever. This throws an exception

The last row is the trap from the start of this article. Sharing a context across parallel tasks is the bug, not the goal.

When parallel actually helps

Running queries in parallel is not free. Each new context opens a new database connection. More connections means more load on your database server and on the connection pool. If you fire off twenty parallel queries, you may starve the pool and make everything slower.

So ask yourself these questions before reaching for parallelism:

QuestionIf yesIf no
Are the queries independent?Parallel may helpKeep them sequential
Are the queries slow on their own?Parallel saves real timeSequential is fine
Can the database handle more connections?Safe to parallelizeLimit or batch them
Did you measure a real bottleneck?Go aheadDo not guess, measure first

A good rule of thumb: start sequential. Only switch to parallel when a profiler or real timing shows that a handful of slow, independent queries are the bottleneck.

A simple decision path for choosing your approach

Common pitfalls to avoid

Even with the factory, a few mistakes can creep in. Here are the ones that trip people up most often.

Forgetting to dispose the context. If you create a context with the factory but never dispose it, you leak connections. Always use await using so the context is cleaned up automatically when the task finishes.

Passing entities between contexts. An entity loaded by Context A is tracked by Context A. If you try to save it through Context B, EF Core gets confused. Keep each entity's whole life inside one context, or detach and reattach carefully.

Sharing the factory result by accident. The factory is safe to share. The contexts it creates are not. Never store a created context in a field and reuse it across tasks. Create, use, dispose, within one task.

Over-parallelizing writes. Reads parallelize well. Writes to the same rows from many contexts can cause deadlocks or lost updates. Be extra careful when parallel tasks modify overlapping data, and lean on database transactions and concurrency tokens.

A real story: the dashboard that crashed under load

Let me walk you through a story that happens in real teams all the time. A developer builds a dashboard page. On their own machine, everything works. One user, one request, no problem. They even added parallel queries on a shared context to "make it faster," and it never failed during testing.

Then the app goes live. Real users arrive. Suddenly the logs fill up with the same error over and over: "A second operation was started on this context instance." The page works sometimes and crashes other times. It feels random and scary.

Why did it pass on the developer's machine? Because timing matters. On a fast local database, the first query sometimes finished before the second one really got going. So the collision did not happen every time. Under real load, with a slower network and busier database, the queries overlapped more often, and the bug showed itself.

This is the dangerous part of thread-safety bugs. They hide. They do not fail every single time. They wait for the worst moment, usually production, to appear. That is exactly why you cannot rely on "it worked when I ran it." You must follow the rule by design, not by luck.

The fix in this story was small. The team swapped the shared context for IDbContextFactory, gave each query its own context, and the errors vanished. The lesson is bigger than the fix: respect the notebook rule from the start, and you never meet this monster at all.

How a hidden timing bug shows up only under real load

What about background jobs and Blazor?

The same notebook rule applies beyond web requests. In a background worker (like a hosted service that runs all night), there is no per-request scope to lean on, so the factory is the natural fit. You create a context, do a chunk of work, dispose it, and repeat. This keeps memory low and connections healthy over long runs.

Blazor Server apps are another place where this bites people. A Blazor circuit can live for a long time, and several components may try to use the same injected context at once. Microsoft's own guidance recommends IDbContextFactory for Blazor Server for exactly this reason. Each operation grabs a short-lived context, uses it, and lets it go. Same idea, same safety, different setting.

A note on async LINQ in .NET 10

If you process query results as streams, you may use IAsyncEnumerable<T>. In .NET 10, LINQ operators over IAsyncEnumerable<T> ship in the box, so you can write Where, Select, and friends over async streams without an extra package. On older .NET versions you needed the System.Linq.Async package. This does not change the thread-safety rule, but it makes streaming results cleaner. The notebook rule still holds: one context per worker.

Quick recap

  • A DbContext is like a single notebook. It handles one job at a time and is not thread-safe.
  • Running parallel queries on one shared context throws an InvalidOperationException.
  • The simplest safe habit is to await each query immediately, one after another.
  • For true parallel work, use IDbContextFactory<T> and give each task its own context.
  • Wrap each created context in await using so it gets disposed and the connection is returned.
  • Parallel queries cost extra connections and load. Use them only for slow, independent queries, and measure first.
  • Never pass tracked entities between contexts, and be careful with parallel writes.

References and further reading

Related Posts