EF Core 9/10 Changes
Key Points
- EF Core 8 introduced complex types (lighter than owned), primitive collections (store
List<int>as JSON), JSON columns for SQL Server / SQLite, bulk operations (ExecuteUpdate/Delete). - EF Core 9 added HierarchyId (SQL Server),
AsOf/ temporal queries, better translation of LINQ patterns, smaller compiled-model output. - EF Core 10 brings vector search (Azure SQL, SQL Server 2025 vector index), parameterized collections (better
INtranslation for big collections), GroupBy improvements. - For NativeAOT: EF Core supports compiled models — generate via
dotnet ef dbcontext optimize. - Migration story improving but plan-then-deploy still recommended.
Concepts (deep dive)
Complex types (EF 8+)
public class Order
{
public Address ShippingAddress { get; set; } = new(); // value object
}
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<Order>().ComplexProperty(o => o.ShippingAddress);
}
ComplexProperty is lighter than OwnsOne: - No identity (so no separate change-tracker entry). - No navigation; can't be queried independently. - Stored as columns in the parent's table.
Use for pure value objects (Address, Money, Coordinates).
Primitive collections (EF 8+)
public class Order
{
public int[] TagIds { get; set; } = Array.Empty<int>();
public List<string> Notes { get; set; } = new();
}
EF persists these as JSON columns automatically (or as a separate table — configurable). Queryable:
db.Orders.Where(o => o.TagIds.Contains(5)).ToList();
// SQL: WHERE 5 IN (SELECT [t].[value] FROM OPENJSON(o.TagIds))
JSON columns (EF 8+)
public class Customer
{
public ContactInfo Info { get; set; } = new();
}
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<Customer>().OwnsOne(c => c.Info, builder => builder.ToJson());
}
ToJson() stores the owned type as a JSON column. Queryable:
db.Customers.Where(c => c.Info.Phone == "...").ToList();
// SQL: WHERE JSON_VALUE([c].[Info], '$.Phone') = '...'
Bulk operations (EF 7+)
Already covered — ExecuteUpdate, ExecuteDelete. EF 9/10 improved performance and added more translation patterns.
Temporal queries (EF 9+; SQL Server)
public class Order { ... }
b.Entity<Order>().ToTable("Orders", t => t.IsTemporal());
// Query as-of a point in time:
var historical = await db.Orders.TemporalAsOf(asOf).Where(o => o.Id == 1).ToListAsync();
Requires SQL Server temporal tables (system-versioned). Useful for audit / point-in-time queries.
HierarchyId (EF 8+; SQL Server)
public class Category
{
public int Id { get; set; }
public HierarchyId Path { get; set; } = HierarchyId.GetRoot();
}
// Query descendants:
var subtree = db.Categories.Where(c => c.Path.IsDescendantOf(parentPath)).ToList();
For tree-shaped data on SQL Server. First-class support landed in EF 8 via the Microsoft.EntityFrameworkCore.SqlServer.HierarchyId package — no more raw SQL workarounds. EF 9 added a sugar method (HierarchyId.Parse(parent, ...)) for building child paths without explicit string manipulation.
Vector search (EF 10; SQL Server 2025+)
public class Document
{
public int Id { get; set; }
public string Content { get; set; } = "";
public float[] Embedding { get; set; } = Array.Empty<float>();
}
b.Entity<Document>().Property(d => d.Embedding).HasColumnType("vector(1536)");
// Top-K cosine similarity:
var results = await db.Documents
.OrderBy(d => EF.Functions.VectorDistance(d.Embedding, queryEmbedding))
.Take(10)
.ToListAsync();
Backed by SQL Server 2025's vector type with optional vector index. See RAG & Vector Stores.
Parameterized collections (EF 10)
Before:
var ids = new[] { 1, 2, 3, ..., 1000 };
db.Orders.Where(o => ids.Contains(o.Id)).ToList();
// SQL: ... WHERE Id IN (1, 2, 3, ..., 1000) — query plan cache miss per distinct N
EF 10 emits a parameterized IN (or table-valued parameter for SQL Server), reusing query plans regardless of N. Better cache hit rate; less SQL Server CPU.
Compiled models for AOT
Generates an entity-by-entity compiled model that EF Core uses at startup — no reflection-driven model building. Required for NativeAOT. Significant cold-start improvement otherwise.
EF.Functions extensions
// String functions
db.Customers.Where(c => EF.Functions.Like(c.Name, "Smith%")).ToList();
db.Customers.Where(c => EF.Functions.FreeText(c.Bio, "engineer")).ToList();
// JSON functions
db.Customers.Where(c => EF.Functions.JsonContains(c.Tags, "vip")).ToList();
// Vector functions (EF 10)
.OrderBy(d => EF.Functions.VectorDistance(d.Embedding, query));
EF.Functions exposes provider-specific SQL functions in a translatable LINQ form.
Code: correct vs wrong
❌ Wrong: OwnsOne for pure value object (overkill)
✅ Correct: ComplexProperty (EF 8+)
❌ Wrong: separate table for tags array
✅ Correct: primitive collection
public class Order { public int[] TagIds { get; set; } = Array.Empty<int>(); }
// EF stores as JSON; queryable
❌ Wrong: IN over thousands of IDs
var ids = (await GetActive(...)).Select(c => c.Id).ToArray(); // 5000 IDs
db.Orders.Where(o => ids.Contains(o.Id)).ToList(); // huge SQL parameter list
✅ Correct: parameterized collection (EF 10) or temp table
EF 10 handles this efficiently; on older EF, batch and join:
const int batchSize = 1000;
foreach (var batch in ids.Chunk(batchSize))
{
var part = await db.Orders.Where(o => batch.Contains(o.Id)).ToListAsync();
/* ... */
}
Design patterns for this topic
Pattern 1 — "ComplexProperty for value objects"
- Intent: lighter than
OwnsOne.
Pattern 2 — "Primitive collections instead of join tables"
- Intent: simpler model; JSON storage.
Pattern 3 — "JSON columns for flexible nested data"
- Intent: schema-less embedded data.
Pattern 4 — "Compiled model for AOT and cold start"
- Intent: zero runtime model building.
Pattern 5 — "Vector columns for embeddings"
- Intent: SQL-native semantic search.
Pros & cons / trade-offs
| Feature | Pros | Cons |
|---|---|---|
| Complex types | Lighter VO support | EF 8+ only |
| Primitive collections | No join tables | JSON parsing cost |
| JSON columns | Flexible nested data | Loose schema |
| Temporal queries | Built-in audit | SQL Server only |
| Vector search | SQL-native | DB version constraints |
| Compiled model | Faster startup; AOT | Generation step |
When to use / when to avoid
- Use ComplexProperty for value objects (EF 8+).
- Use primitive collections for small lists.
- Use JSON columns for genuinely schemaless nested data.
- Use compiled models for AOT; for everyone with cold-start sensitivity.
- Avoid OwnsOne when ComplexProperty fits.
- Avoid massive
INlists — let EF 10's parameterized collections do it, or batch.
Interview Q&A
Q1. Difference between OwnsOne and ComplexProperty? ComplexProperty (EF 8+) is lighter — no identity, no separate change tracker entry. Use for pure value objects.
Q2. What's a primitive collection? A property of type List<T> / T[] (T is a primitive) that EF stores as JSON column. Queryable.
Q3. What's ToJson()? On owned types: store as JSON column instead of separate table.
Q4. What's a temporal table? SQL Server feature: every change versioned in a history table. Query AsOf(time) to see past state. EF 9 wraps it.
Q5. What's the compiled model for? Pre-builds the EF model at compile time. Faster cold start; required for NativeAOT.
Q6. What changed in EF 10 around IN? Parameterized collections — Where(o => ids.Contains(o.Id)) produces a single parameterized SQL regardless of ids length, improving query-plan cache hit rate.
Q7. Vector search in EF 10? SQL-native vector type + similarity operators. Backed by SQL Server 2025's vector index. See AI section.
Q8. How do you generate a compiled model? dotnet ef dbcontext optimize --output-dir Models --namespace MyApp.Models.
Q9. What's EF.Functions? Provider-specific SQL functions exposed in LINQ-translatable form (Like, FreeText, VectorDistance, etc.).
Q10. Migration considerations for AOT? Compiled model required. Migrations themselves run via dotnet ef migrations SDK at design time, not at runtime — works fine.
Gotchas / common mistakes
- ⚠️ OwnsOne when ComplexProperty fits — extra change-tracker entries.
- ⚠️ Primitive collection on hot path without index — JSON scan.
- ⚠️ JSON columns assumed indexable — check provider; many require functional indexes.
- ⚠️ Temporal tables without retention policy — history table grows forever.
- ⚠️ Vector search without vector index — sequential scan.