Skip to content

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 IN translation 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

dotnet ef dbcontext optimize --output-dir Models --namespace MyApp.Models

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)

b.Entity<Order>().OwnsOne(o => o.Address);   // creates change-tracker entry

✅ Correct: ComplexProperty (EF 8+)

b.Entity<Order>().ComplexProperty(o => o.Address);

❌ Wrong: separate table for tags array

public class OrderTag { public int OrderId; public int TagId; }   // unnecessary join table

✅ 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 IN lists — 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 indexJSON 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.

Further reading