EF Core 10 Deep Dive: Vector Search, JSON Type, Named Filters & LeftJoin
Posted on: 4/22/2026 10:13:50 PM
Table of contents
- 1. EF Core 10 Overview — The Core Changes
- 2. Vector Search — When Your ORM Meets AI
- 3. Native JSON Data Type — No More nvarchar(max)
- 4. Named Query Filters — Managing Filters by Name
- 5. LINQ LeftJoin — 8 Years in the Making
- 6. Parameterized Collections — Solving Plan Cache Bloat
- 7. Improved Complex Types — Replacing Owned Entities
- 8. ExecuteUpdateAsync — No More Expression Trees
- 9. Security — Safer Defaults
- 10. Split Query Fix and Other Improvements
- 11. Migration Strategy — Upgrading from EF Core 8/9
- 12. Benchmarks — EF Core 10 vs EF Core 9
- Conclusion
- References
When Entity Framework Core 10 shipped alongside .NET 10 LTS in November 2025, it brought the most significant changes since EF Core 8. These aren't incremental improvements — EF Core 10 directly addresses pain points developers have been waiting years for: integrated Vector Search for AI workloads, native JSON data type on SQL Server 2025, Named Query Filters for flexible multi-tenancy, and LINQ LeftJoin — the most-voted feature request on GitHub. This article dives deep into each feature with production-ready code and real-world trade-off analysis.
1. EF Core 10 Overview — The Core Changes
EF Core 10 is not just a routine update. As a Long Term Support (LTS) release supported until November 2028, it focuses on four main pillars:
graph TB
EF["EF Core 10
.NET 10 LTS"]
EF --> AI["AI & Vector
SqlVector, VectorDistance
Cosmos Hybrid Search"]
EF --> JSON["Native JSON
json data type
ExecuteUpdate JSON
Complex Types → JSON"]
EF --> LINQ["LINQ Upgrades
LeftJoin / RightJoin
Parameterized Collections
Split Query Fix"]
EF --> DX["Developer Experience
Named Query Filters
Non-expression ExecuteUpdate
Security Logging"]
style EF fill:#e94560,stroke:#fff,color:#fff
style AI fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style JSON fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style LINQ fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style DX fill:#f8f9fa,stroke:#e94560,color:#2c3e50
The four main pillars of EF Core 10
2. Vector Search — When Your ORM Meets AI
This is the headline feature. EF Core 10 fully supports the new vector data type on Azure SQL Database and SQL Server 2025, enabling storage and similarity search of embeddings directly within your relational database — no need for specialized vector databases like Pinecone or Qdrant.
2.1 Defining an Entity with Vector
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
[Column(TypeName = "vector(1536)")]
public SqlVector<float> DescriptionEmbedding { get; set; }
}
The [Column(TypeName = "vector(1536)")] annotation tells EF this column stores a 1536-dimensional vector — the standard size for OpenAI text-embedding-3-small. On SQL Server 2025, it generates:
CREATE TABLE [Products] (
[Id] int NOT NULL IDENTITY,
[Name] nvarchar(max) NOT NULL,
[Description] nvarchar(max) NOT NULL,
[DescriptionEmbedding] vector(1536) NOT NULL,
CONSTRAINT [PK_Products] PRIMARY KEY ([Id])
);
2.2 Inserting Embedding Data
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator = ...;
var embedding = await embeddingGenerator.GenerateVectorAsync(product.Description);
product.DescriptionEmbedding = new SqlVector<float>(embedding);
await context.SaveChangesAsync();
2.3 Similarity Search with LINQ
var queryVector = await embeddingGenerator.GenerateVectorAsync("lightweight laptop long battery");
var sqlVector = new SqlVector<float>(queryVector);
var similarProducts = await context.Products
.OrderBy(p => EF.Functions.VectorDistance("cosine", p.DescriptionEmbedding, sqlVector))
.Take(10)
.ToListAsync();
EF Core translates this to SQL using the native VECTOR_DISTANCE() function:
SELECT TOP(10) [p].[Id], [p].[Name], [p].[Description], [p].[DescriptionEmbedding]
FROM [Products] AS [p]
ORDER BY VECTOR_DISTANCE('cosine', [p].[DescriptionEmbedding], @sqlVector)
When to use Vector Search in SQL Server?
If you already have SQL Server as your primary database and need semantic search for <10 million vectors, this is an excellent choice — it significantly reduces infrastructure complexity compared to maintaining a separate vector database. For larger scale or complex ANN (Approximate Nearest Neighbor) requirements, consider dedicated vector databases.
3. Native JSON Data Type — No More nvarchar(max)
Before EF Core 10, when you mapped entities to JSON columns, EF stored them as nvarchar(max) — essentially just plain text strings. SQL Server 2025 introduces the native json data type, providing much better performance and data integrity.
3.1 Automatic Migration to JSON Type
If you configure UseAzureSql() or compatibility level 170+, EF Core 10 automatically uses json instead of nvarchar(max):
public class Order
{
public int Id { get; set; }
public string CustomerName { get; set; }
public OrderDetails Details { get; set; }
public string[] Tags { get; set; }
}
public class OrderDetails
{
public string ShippingMethod { get; set; }
public decimal TotalAmount { get; set; }
public Address ShippingAddress { get; set; }
}
// In OnModelCreating
modelBuilder.Entity<Order>()
.ComplexProperty(o => o.Details, d => d.ToJson());
SQL Server 2025 creates the table:
CREATE TABLE [Orders] (
[Id] int NOT NULL IDENTITY,
[CustomerName] nvarchar(max) NOT NULL,
[Details] json NOT NULL, -- native json, not nvarchar!
[Tags] json NOT NULL, -- primitive collections also use json
CONSTRAINT [PK_Orders] PRIMARY KEY ([Id])
);
3.2 Querying JSON Properties with RETURNING Clause
var expensiveOrders = await context.Orders
.Where(o => o.Details.TotalAmount > 1000)
.OrderByDescending(o => o.Details.TotalAmount)
.ToListAsync();
EF Core 10 generates SQL leveraging JSON_VALUE ... RETURNING — returning the correct data type instead of casting from string:
SELECT [o].[Id], [o].[CustomerName], [o].[Details], [o].[Tags]
FROM [Orders] AS [o]
WHERE JSON_VALUE([o].[Details], '$.TotalAmount' RETURNING decimal(18,2)) > 1000.00
ORDER BY JSON_VALUE([o].[Details], '$.TotalAmount' RETURNING decimal(18,2)) DESC
3.3 Bulk Update JSON with ExecuteUpdateAsync
This is entirely new in EF Core 10. Previously, you couldn't use ExecuteUpdateAsync to update properties inside JSON columns. Now you can:
// Increase TotalAmount by 10% for all orders
await context.Orders.ExecuteUpdateAsync(s =>
s.SetProperty(
o => o.Details.TotalAmount,
o => o.Details.TotalAmount * 1.1m
));
On SQL Server 2025, EF generates SQL using the new modify() function — updating directly within the JSON document without reading the entire thing:
UPDATE [o]
SET [Details].modify('$.TotalAmount',
JSON_VALUE([o].[Details], '$.TotalAmount' RETURNING decimal(18,2)) * 1.1)
FROM [Orders] AS [o]
Important note about Complex Types vs Owned Entities
ExecuteUpdate for JSON only works with Complex Types, not with Owned Entities. If you're using .OwnsOne() for JSON mapping, consider migrating to .ComplexProperty() — Microsoft also recommends this since Complex Types have value semantics that are more appropriate for JSON modeling.
4. Named Query Filters — Managing Filters by Name
Global Query Filters have existed since EF Core 2.0, but they've always had a major limitation: each entity could only have a single filter, and disabling meant disabling all of them with IgnoreQueryFilters(). EF Core 10 solves this completely.
4.1 Naming Individual Filters
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
modelBuilder.Entity<Invoice>()
.HasQueryFilter("SoftDelete", i => !i.IsDeleted)
.HasQueryFilter("TenantIsolation", i => i.TenantId == tenantId)
.HasQueryFilter("ActiveOnly", i => i.Status != InvoiceStatus.Archived);
}
4.2 Selective Disabling
// Admin needs to see soft-deleted invoices, but still keep tenant isolation
var deletedInvoices = await context.Invoices
.IgnoreQueryFilters(["SoftDelete"])
.Where(i => i.IsDeleted)
.ToListAsync();
// Aggregate report: skip SoftDelete and ActiveOnly, keep TenantIsolation
var allInvoices = await context.Invoices
.IgnoreQueryFilters(["SoftDelete", "ActiveOnly"])
.ToListAsync();
// Superadmin: skip all filters
var everything = await context.Invoices
.IgnoreQueryFilters()
.ToListAsync();
graph LR
Q["LINQ Query"] --> F1["SoftDelete Filter
!IsDeleted"]
Q --> F2["Tenant Filter
TenantId == current"]
Q --> F3["Active Filter
Status != Archived"]
F1 -->|"IgnoreQueryFilters(['SoftDelete'])"| X1["Skipped"]
F2 --> R["SQL WHERE clause"]
F3 --> R
style Q fill:#e94560,stroke:#fff,color:#fff
style X1 fill:#ff9800,stroke:#fff,color:#fff
style R fill:#4CAF50,stroke:#fff,color:#fff
style F1 fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
style F2 fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
style F3 fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
Named Query Filters allow selective bypassing of individual filters
| Feature | EF Core 9 (old) | EF Core 10 (new) |
|---|---|---|
| Filters per entity | Single only | Unlimited, named |
| Disable filter | All or nothing | Selective by name |
| Multi-tenancy + Soft delete | Combined into one complex expression | Separate, independently managed |
| Testing | Hard to mock individual filters | Disable specific filters in tests |
5. LINQ LeftJoin — 8 Years in the Making
Issue #12793 was opened in 2018 and became one of the most upvoted feature requests. Before EF Core 10, writing LEFT JOIN in LINQ was painful:
5.1 The Old Way — SelectMany + GroupJoin + DefaultIfEmpty
// Before EF Core 10: complex and hard to read
var query = context.Students
.GroupJoin(
context.Departments,
student => student.DepartmentId,
department => department.Id,
(student, departments) => new { student, departments })
.SelectMany(
x => x.departments.DefaultIfEmpty(),
(x, department) => new
{
x.student.FirstName,
x.student.LastName,
Department = department != null ? department.Name : "[NONE]"
});
5.2 The New Way — Direct LeftJoin
// EF Core 10: clear and intuitive
var query = context.Students
.LeftJoin(
context.Departments,
student => student.DepartmentId,
department => department.Id,
(student, department) => new
{
student.FirstName,
student.LastName,
Department = department.Name ?? "[NONE]"
});
Both generate the same SQL:
SELECT [s].[FirstName], [s].[LastName],
COALESCE([d].[Name], N'[NONE]') AS [Department]
FROM [Students] AS [s]
LEFT JOIN [Departments] AS [d] ON [s].[DepartmentId] = [d].[Id]
RightJoin is also supported
EF Core 10 also supports the RightJoin operator — keeping all data from the second collection and only matching data from the first. In practice, RightJoin is rarely needed — most cases can simply reverse the collection order and use LeftJoin.
6. Parameterized Collections — Solving Plan Cache Bloat
This is the most impactful performance improvement in EF Core 10, yet many overlook it. The problem: when you use Contains() with an array of values, different arrays generate different SQL → the database must compile a new query plan every time.
6.1 Three Strategies Across Versions
| Version | Strategy | Generated SQL | Problem |
|---|---|---|---|
| EF Core ≤ 7 | Inline constants | WHERE Id IN (1, 2, 3) | Plan cache bloat — each value set = new plan |
| EF Core 8-9 | JSON parameter | WHERE Id IN (SELECT value FROM OPENJSON(@p)) | Planner doesn't know cardinality → poor plan |
| EF Core 10 | Scalar parameters + padding | WHERE Id IN (@p1, @p2, @p3) | Best balance between plan reuse and cardinality |
6.2 Padding — The Clever Technique
int[] productIds = [101, 102, 103, 104, 105, 106, 107, 108];
var products = await context.Products
.Where(p => productIds.Contains(p.Id))
.ToListAsync();
EF Core 10 generates SQL with 10 parameters (padded from 8 to the nearest power):
SELECT [p].[Id], [p].[Name]
FROM [Products] AS [p]
WHERE [p].[Id] IN (@ids1, @ids2, @ids3, @ids4, @ids5, @ids6, @ids7, @ids8, @ids9, @ids10)
-- @ids9 and @ids10 repeat the value of @ids8 to keep results correct
Why padding? If you query with 8 items then 9 items, instead of creating 2 different plans, EF pads both to 10 → sharing 1 plan. This technique significantly reduces the number of plans in cache.
6.3 Customizing Strategy Per-Query
// Global: use JSON parameter (good for large collections > 100 items)
optionsBuilder.UseSqlServer(connStr, o =>
o.UseParameterizedCollectionMode(ParameterTranslationMode.JsonArray));
// Per-query: force inline constants (when values are fixed)
var statuses = new[] { OrderStatus.Pending, OrderStatus.Processing };
var orders = await context.Orders
.Where(o => EF.Constant(statuses).Contains(o.Status))
.ToListAsync();
7. Improved Complex Types — Replacing Owned Entities
EF Core 10 elevates Complex Types to a new level, making them the clear choice over Owned Entities for JSON and table splitting modeling.
7.1 Optional Complex Types
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
public Address ShippingAddress { get; set; } // required
public Address? BillingAddress { get; set; } // optional - NEW in EF 10!
}
public struct Address // structs are also supported!
{
public required string Street { get; set; }
public required string City { get; set; }
public required string ZipCode { get; set; }
}
7.2 Value Semantics — Why Complex Types Are Better Than Owned Entities
// With Complex Types: works correctly due to value semantics
var customer = await context.Customers.SingleAsync(c => c.Id == 1);
customer.BillingAddress = customer.ShippingAddress; // Copies values
await context.SaveChangesAsync(); // Success
// With Owned Entities: CRASH due to reference semantics
// Same entity cannot be referenced twice
// LINQ comparison also works correctly with Complex Types
var sameBilling = await context.Customers
.Where(c => c.ShippingAddress == c.BillingAddress)
.ToListAsync();
// Complex Types: compares content → correct
// Owned Entities: compares identity → incorrect
| Aspect | Owned Entities | Complex Types (EF 10) |
|---|---|---|
| Semantics | Reference (entity) | Value (no identity) |
| Assignment | Errors when assigning same instance | Auto-copies properties |
| LINQ comparison | Compares identity | Compares content |
| ExecuteUpdate JSON | Not supported | Fully supported |
| Optional (nullable) | Yes | Yes (new in EF 10) |
| Struct support | No | Yes |
| Table splitting | Yes | Yes |
| JSON mapping | Yes | Yes (with ToJson()) |
8. ExecuteUpdateAsync — No More Expression Trees
A small change with big impact: ExecuteUpdateAsync now accepts a regular lambda instead of an expression tree, making dynamic updates dramatically easier to build.
8.1 Before EF Core 10 — Complex Expression Trees
// Want conditional updates? Must build expression trees manually
Expression<Func<SetPropertyCalls<Blog>, SetPropertyCalls<Blog>>> setters =
s => s.SetProperty(b => b.Views, 8);
if (nameChanged)
{
// 15 lines of Expression.Lambda, Expression.Call...
// Complex, error-prone, hard to maintain
}
await context.Blogs.ExecuteUpdateAsync(setters);
8.2 EF Core 10 — Simple Lambda
await context.Blogs.ExecuteUpdateAsync(s =>
{
s.SetProperty(b => b.Views, 8);
if (nameChanged)
{
s.SetProperty(b => b.Name, "New Name");
}
if (updateTimestamp)
{
s.SetProperty(b => b.ModifiedAt, DateTime.UtcNow);
}
});
Real-world production use
This pattern is extremely useful for admin panels or bulk operations where users choose which fields to update. Previously, many teams had to use raw SQL or complex dynamic LINQ libraries. Now it's just if/else inside a lambda.
9. Security — Safer Defaults
9.1 Redacting Inlined Constants in Logs
EF Core 10 automatically redacts constant values inlined into SQL in log output. Previously, if you used EF.Constant(), actual values would appear in logs — potentially leaking sensitive information.
// Code using EF.Constant to inline values
var users = await context.Users
.Where(u => EF.Constant(roles).Contains(u.Role))
.ToListAsync();
-- EF Core 9 log: exposes values
SELECT [u].[Id], [u].[Role] FROM [Users] WHERE [u].[Role] IN (N'Administrator', N'Manager')
-- EF Core 10 log: values redacted
SELECT [u].[Id], [u].[Role] FROM [Users] WHERE [u].[Role] IN (?, ?)
9.2 SQL Injection Analyzer
EF Core 10 adds a Roslyn analyzer that warns when you concatenate strings within FromSqlRaw methods:
// Warning: SQL injection risk
var users = context.Users.FromSqlRaw(
"SELECT * FROM Users WHERE [" + fieldName + "] IS NULL");
// Safe: use FromSql with interpolation
var users = context.Users.FromSql(
$"SELECT * FROM Users WHERE [{fieldName}] IS NULL");
10. Split Query Fix and Other Improvements
10.1 Consistent Ordering for Split Queries
Before EF Core 10, split queries could return inconsistent data due to missing ordering in subqueries. This was a subtle bug — it only manifested when data changed between the two queries:
var blogs = await context.Blogs
.AsSplitQuery()
.Include(b => b.Posts)
.OrderBy(b => b.Name)
.Take(10)
.ToListAsync();
EF Core 10 ensures consistent ordering between the main query and split queries — preventing potential data corruption.
10.2 Custom Default Constraint Names
modelBuilder.Entity<Post>()
.Property(p => p.CreatedDate)
.HasDefaultValueSql("GETDATE()", "DF_Post_CreatedDate");
// Or enable auto-naming for the entire model
modelBuilder.UseNamedDefaultConstraints();
10.3 Cosmos DB Improvements
Beyond Vector Search and Full-Text Search already mentioned, EF Core 10 improves the Cosmos DB experience when evolving models: adding a new required property to an entity will no longer crash when reading old documents — EF automatically materializes default values instead of throwing exceptions.
11. Migration Strategy — Upgrading from EF Core 8/9
graph TB
A["Assess current state"] --> B{"Using Owned Entities
for JSON?"}
B -->|Yes| C["Migrate to
Complex Types"]
B -->|No| D["Update
target framework"]
C --> D
D --> E{"SQL Server 2025?"}
E -->|Yes| F["Enable json type
+ Vector Search"]
E -->|No| G["Keep nvarchar JSON
Still get LeftJoin + Filters"]
F --> H["Add Named
Query Filters"]
G --> H
H --> I["Review
Parameterized Collections"]
I --> J["Run benchmarks
compare performance"]
style A fill:#e94560,stroke:#fff,color:#fff
style J fill:#4CAF50,stroke:#fff,color:#fff
style C fill:#ff9800,stroke:#fff,color:#fff
style F fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style G fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
style H fill:#f8f9fa,stroke:#e94560,color:#2c3e50
Upgrade roadmap to EF Core 10
Breaking change to watch for
If you already have JSON columns as nvarchar(max) on SQL Server 2025, the first migration will automatically convert them to the json type. To opt out, set the column type manually: .HasColumnType("nvarchar(max)") or use compatibility level < 170.
12. Benchmarks — EF Core 10 vs EF Core 9
Conclusion
EF Core 10 is the most worthwhile upgrade since EF Core 8. With LTS support until 2028, now is the ideal time to upgrade. The standout features:
- Vector Search — enables AI/RAG directly within SQL Server, reducing infrastructure complexity
- Native JSON type — significantly improved JSON query performance on SQL Server 2025
- Named Query Filters — finally, multi-tenancy + soft delete + custom filters can be managed independently
- LINQ LeftJoin — 8 years of waiting, now write JOINs clearly and naturally
- Parameterized Collections — significant plan cache performance improvement for production
- Complex Types — replaces Owned Entities for JSON modeling with correct semantics
Whether you're building a multi-tenant SaaS, integrating AI into existing applications, or simply want cleaner LINQ code — EF Core 10 has something for you.
References
On-Device AI 2026: Running LLMs Locally with Ollama, llama.cpp & ONNX Runtime on .NET 10
Durable Execution: Building Crash-Proof Workflows in Distributed Systems
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.