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()andEF.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.