Skip to main content

EF Core 10–Smarter parameterized collections

If you've been using Entity Framework Core for a while, you've probably written a query like this:

var ids = new[] { 1, 2, 3, 4, 5 };
var blogs = await context.Blogs
    .Where(b => ids.Contains(b.Id))
    .ToListAsync();

Simple enough. But under the hood, how EF Core translates that ids collection into SQL has quietly changed(again) in EF Core 10, and this time the change is significant enough to be listed as a breaking change. Let's dig into what's happening, why the EF team made this call, and what it means for your applications.

A brief history

To understand EF Core 10's approach, it helps to know where things stood before.

EF Core 8: The OPENJSON era

In EF Core 8, when you passed a collection to a LINQ Contains or Where clause, EF encoded the entire collection as a JSON string and sent it as a single parameter. SQL Server would then unpack it using the OPENJSON function:

SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b]
WHERE [b].[Id] IN (
    SELECT [value] FROM OPENJSON(@ids)
)
-- @ids = '[1,2,3,4,5]'

This was elegant: one parameter, one query shape, one query plan — regardless of how many items were in the collection. The problem? The SQL Server query planner had no idea how many items were in that JSON array. It couldn't see cardinality. So it would make a guess and sometimes choose a terrible execution plan.

What's New in EF Core 8 | Microsoft Learn

EF Core 9: Choose your strategy

EF Core 9 added the UseParameterizedCollectionMode option and EF.Parameter(), letting you switch between the OPENJSON approach and inlining values as constants. But the default remained OPENJSON. You had to opt in to the alternative.

SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b]
WHERE [b].[Id] IN (1,2,3,4,5)

What's New in EF Core 9 | Microsoft Learn

EF Core 10: Multiple scalar parameters by default

EF Core 10 changes the default entirely. Now, when you write that same Contains query, EF generates individual scalar parameters — one per item in the collection:

SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b]
WHERE [b].[Id] IN (@ids1, @ids2, @ids3, @ids4, @ids5)

This gives the query planner exactly what it needs: it knows there are five values, can estimate selectivity accurately, and can choose a more appropriate execution plan.

What's New in EF Core 10 | Microsoft Learn

Why multiple parameters?

The old OPENJSON approach had a subtle but real problem. Database query planners use statistics to estimate how many rows a filter will match. When your filter is WHERE id IN (SELECT value FROM OPENJSON(@json)), the planner sees an opaque subquery. It makes a fixed guess about the result set size.

With individual parameters (WHERE id IN (@p1, @p2, @p3)), the planner knows exactly how many values it's dealing with. That cardinality information can be the difference between a seek and a scan, especially on large tables.

There's also an important secondary benefit: plan caching. Rather than caching a plan per unique JSON blob, the database can recognize the same query shape even with different parameter values — improving reuse across similar queries.

Parameter padding

Here's where EF Core 10 gets clever. Naive multiple-parameter generation creates a problem: every unique collection size produces a unique query shape, meaning a different query plan cached for each distinct n. Ten items in ids? Different plan from nine. Different from eleven.

EF Core 10 solves this with parameter padding. Instead of generating exactly as many parameters as items, it rounds the count up to the next bucket size. So a collection of 6 items might be padded to 8, with the extra parameters filled with NULL or repeated values.

This dramatically reduces the number of distinct query shapes, improving plan cache hit rates while still giving the planner meaningful cardinality information.

The breaking change

The EF team classifies this as a low-impact breaking change, but it can have real consequences depending on your workload.

If your collections are always small (say, under 20 items), the new default is almost certainly better. You get better plans, still-reasonable parameter counts, and improved caching.

If your collections are large (hundreds or thousands of items), the old OPENJSON approach may actually be more efficient. Sending 500 individual SQL parameters is a lot of wire traffic and can hit SQL Server's parameter limit.

If you're upgrading from EF Core 8, pay special attention. Any code that relied on OPENJSON's behavior — including specific query hints, execution plan tuning, or even workarounds for OPENJSON edge cases — may behave differently after the upgrade.

Controlling the behavior

EF Core 10 gives you full control over the translation strategy.

Keep the EF Core 10 default

Do nothing. The new multiple-parameters-with-padding approach is the default and generally produces better results for typical web application workloads.

Opt back into OPENJSON globally

If you have large collections or have tuned your database for the OPENJSON approach, you can restore the old behavior globally via DbContext options:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder.UseSqlServer(connectionString, options =>
    {
        options.UseParameterizedCollectionMode(
            ParameterTranslationMode.Parameter);
    });
}

Mix strategies per query

You can also control behavior at the query level using EF.Parameter():

// Force OPENJSON for this query (useful for large collections)
var largeList = Enumerable.Range(1, 500).ToList();
var blogs = await context.Blogs
    .Where(b => EF.Parameter(largeList).Contains(b.Id))
    .ToListAsync();

Or force constants (no parameterization at all) with EF.Constant():

// Inline as SQL literals — no plan caching, but maximum planner visibility
var smallList = new[] { 1, 2, 3 };
var blogs = await context.Blogs
    .Where(b => EF.Constant(smallList).Contains(b.Id))
    .ToListAsync();

Per-context default with query-level override

For fine-grained control, configure a global default and override selectively:

// Globally use constants (for small, stable lists)
optionsBuilder.TranslateParameterizedCollectionsToConstants();

// Override per query with EF.Parameter() where parameterization matters
var dynamicIds = GetIdsFromUserInput();
var results = await context.Blogs
    .Where(b => EF.Parameter(dynamicIds).Contains(b.Id))
    .ToListAsync();

Checking your generated SQL

The best way to validate which strategy EF is using is to log the SQL it generates. In development, enable simple logging:

optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information);

Or use ToQueryString() to inspect without executing:

var ids = new[] { 1, 2, 3 };
var query = context.Blogs.Where(b => ids.Contains(b.Id));
Console.WriteLine(query.ToQueryString());

You'll immediately see whether you're getting IN (@p1, @p2, @p3), IN (SELECT value FROM OPENJSON(@p)), or IN (1, 2, 3).

Summary

  • EF Core 8 used OPENJSON by default — clean SQL, but opaque to the query planner.
  • EF Core 9 introduced translation strategy options but kept OPENJSON as the default.
  • EF Core 10 changes the default to multiple scalar parameters with padding, giving the query planner cardinality information and improving plan cache efficiency.
  • The change is a breaking change for workloads that relied on OPENJSON behavior or have very large collections.
  • You can opt back into OPENJSON globally or per-query, and mix strategies using EF.Parameter() and EF.Constant().

For most applications doing typical CRUD with moderate-sized collections, this change is a quiet improvement you don't need to think about. For high-throughput systems or those passing large collections, it's worth a deliberate look.

Popular posts from this blog

Podman– Command execution failed with exit code 125

After updating WSL on one of the developer machines, Podman failed to work. When we took a look through Podman Desktop, we noticed that Podman had stopped running and returned the following error message: Error: Command execution failed with exit code 125 Here are the steps we tried to fix the issue: We started by running podman info to get some extra details on what could be wrong: >podman info OS: windows/amd64 provider: wsl version: 5.3.1 Cannot connect to Podman. Please verify your connection to the Linux system using `podman system connection list`, or try `podman machine init` and `podman machine start` to manage a new Linux VM Error: unable to connect to Podman socket: failed to connect: dial tcp 127.0.0.1:2655: connectex: No connection could be made because the target machine actively refused it. That makes sense as the podman VM was not running. Let’s check the VM: >podman machine list NAME         ...

Azure DevOps/ GitHub emoji

I’m really bad at remembering emoji’s. So here is cheat sheet with all emoji’s that can be used in tools that support the github emoji markdown markup: All credits go to rcaviers who created this list.

VS Code Planning mode

After the introduction of Plan mode in Visual Studio , it now also found its way into VS Code. Planning mode, or as I like to call it 'Hannibal mode', extends GitHub Copilot's Agent Mode capabilities to handle larger, multi-step coding tasks with a structured approach. Instead of jumping straight into code generation, Planning mode creates a detailed execution plan. If you want more details, have a look at my previous post . Putting plan mode into action VS Code takes a different approach compared to Visual Studio when using plan mode. Instead of a configuration setting that you can activate but have limited control over, planning is available as a separate chat mode/agent: I like this approach better than how Visual Studio does it as you have explicit control when plan mode is activated. Instead of immediately diving into execution, the plan agent creates a plan and asks some follow up questions: You can further edit the plan by clicking on ‘Open in Editor’: ...