Complex types make it easier to model structured values inside an EF Core entity.
Instead of flattening every property into the entity itself, an application can group related values into a meaningful type:
public class Customer
{
public int Id { get; set; }
public required string Name { get; set; }
public required Address Address { get; set; }
}
public class Address
{
public required string City { get; set; }
public required string PostalCode { get; set; }
}
A query can then naturally express the domain requirement:
var customers = await db.Customers
.Where(c => c.Address.PostalCode == postalCode)
.ToListAsync();
The challenge is that applications eventually need to answer a database-performance question:
What happens when a complex-type property becomes a heavily queried field at scale?
EF Core 11 introduces support for indexes over scalar properties nested inside complex types. It also supports composite indexes that combine regular entity properties with complex-type properties. For complex types mapped to JSON columns, providers can additionally support indexes over paths inside the JSON document.
That makes complex-type indexing a useful candidate for a controlled benchmark.
The objective should not be to claim that an index is always faster.
The objective is to measure:
Why Complex-Type Indexes Matter
Consider a customer table containing:
Customer
├── Id
├── Name
├── Region
└── Address
├── City
└── PostalCode
A common query might be:
var customers = await db.Customers
.Where(c => c.Address.PostalCode == "842001")
.ToListAsync();
Without a relevant index, the database may need to inspect a large portion of the table.
With an appropriate index:
Query
↓
PostalCode Index
↓
Matching rows
The database can potentially reduce the amount of data it needs to examine.
The actual execution strategy depends on the database engine, statistics, cardinality, selectivity, and provider.
Therefore, the benchmark must inspect the execution plan rather than assuming that the presence of an index guarantees a particular access method.
EF Core 11 Index Configuration
EF Core 11 allows an index to traverse a scalar property inside a complex type.
For example:
protected override void OnModelCreating(
ModelBuilder modelBuilder)
{
modelBuilder.Entity<Customer>()
.HasIndex(c => c.Address.PostalCode);
}
Microsoft documents this capability as part of EF Core 11's complex-type improvements.
A composite index can also combine an ordinary entity property with a complex-type property:
modelBuilder.Entity<Customer>()
.HasIndex(c => new
{
c.Region,
c.Address.PostalCode
});
This becomes particularly interesting when queries filter by both values.
Build the Benchmark Model
Start with a representative model:
public class Customer
{
public int Id { get; set; }
public required string Name { get; set; }
public required string Region { get; set; }
public required Address Address { get; set; }
public DateTime CreatedAt { get; set; }
}
[ComplexType]
public class Address
{
public required string City { get; set; }
public required string PostalCode { get; set; }
}
Configure the complex type:
protected override void OnModelCreating(
ModelBuilder modelBuilder)
{
modelBuilder.Entity<Customer>()
.ComplexProperty(c => c.Address);
}
Then create two database configurations.
Configuration A: No Complex-Type Index
protected override void OnModelCreating(
ModelBuilder modelBuilder)
{
modelBuilder.Entity<Customer>()
.ComplexProperty(c => c.Address);
}
Configuration B: Indexed Complex Property
protected override void OnModelCreating(
ModelBuilder modelBuilder)
{
modelBuilder.Entity<Customer>()
.ComplexProperty(c => c.Address);
modelBuilder.Entity<Customer>()
.HasIndex(c => c.Address.PostalCode);
}
The database schema and dataset should otherwise remain equivalent.
Use Production-Like Data Volumes
A benchmark using a few thousand rows may produce misleading results.
Test multiple scales:
100,000 rows
1,000,000 rows
10,000,000 rows
The exact volumes should match the application being evaluated.
The purpose of multiple scales is to determine whether the difference remains stable as the table grows.
For every dataset, record:
| Dataset | Rows | Indexed | Query Time | Reads |
|---|
| Small | 100K | No | Measure | Measure |
| Small | 100K | Yes | Measure | Measure |
| Medium | 1M | No | Measure | Measure |
| Medium | 1M | Yes | Measure | Measure |
| Large | 10M | No | Measure | Measure |
| Large | 10M | Yes | Measure | Measure |
Do not fill these values with theoretical estimates.
Run the benchmark against the actual database.
Measure Selectivity
Index performance depends heavily on selectivity.
Suppose the query is:
.Where(c => c.Address.PostalCode == postalCode)
A postal code that matches:
20 rows
has very different selectivity from one that matches:
500,000 rows
Therefore, benchmark several distributions.
For example:
Highly selective
→ 0.01% of rows
Moderately selective
→ 1% of rows
Low selectivity
→ 25% of rows
These percentages are test scenarios, not universal thresholds.
The benchmark should use distributions representative of the application's actual data.
Capture Generated SQL
Before measuring execution, inspect the SQL generated by EF Core.
var query = db.Customers
.Where(c =>
c.Address.PostalCode == postalCode);
var sql = query.ToQueryString();
Console.WriteLine(sql);
The important question is whether the complex-type property is translated into the expected database column or provider-specific path.
The SQL should be compared between:
No index
and:
Indexed schema
The SQL itself may be identical.
That is expected.
An index normally changes the database's execution plan rather than the LINQ expression or generated predicate.
Inspect the Execution Plan
This is where the benchmark becomes meaningful.
For a relational database, compare:
Without index
↓
Execution Plan A
With index
↓
Execution Plan B
Look for changes such as:
Table scan
Index seek
Index scan
Rows examined
Estimated rows
Actual rows
Sort operations
Key lookups
Join behavior
Do not assume that an index seek is automatically better.
The database optimizer may correctly choose a scan when a large percentage of the table is required.
The benchmark should explain the observed plan rather than forcing a preferred plan.
Benchmark a Simple Equality Predicate
Start with:
var customers = await db.Customers
.Where(c =>
c.Address.PostalCode == postalCode)
.ToListAsync();
Run the query against:
No index
and:
PostalCode index
Measure:
Execution time
Logical reads
CPU
Rows returned
Execution plan
This establishes the basic effect of the complex-type index.
Benchmark Composite Indexes
A single-column index may not be optimal for every workload.
Consider:
var customers = await db.Customers
.Where(c =>
c.Region == region &&
c.Address.PostalCode == postalCode)
.ToListAsync();
Configure:
modelBuilder.Entity<Customer>()
.HasIndex(c => new
{
c.Region,
c.Address.PostalCode
});
Now compare:
No index
PostalCode only
Region only
Region + PostalCode
A benchmark table can look like:
| Index | Query Time | Reads | CPU | Plan |
|---|
| None | Measure | Measure | Measure | Record |
| PostalCode | Measure | Measure | Measure | Record |
| Region | Measure | Measure | Measure | Record |
| Region + PostalCode | Measure | Measure | Measure | Record |
The order of columns in a composite index matters.
For example:
(Region, PostalCode)
and:
(PostalCode, Region)
are not interchangeable for every query workload.
Test the actual predicates your application uses.
Test Range Queries
Do not benchmark only equality.
Complex-type properties may also be used in range predicates where the underlying type supports them.
For example:
var customers = await db.Customers
.Where(c =>
c.Address.PostalCode.CompareTo(
minimumPostalCode) >= 0)
.ToListAsync();
Whether this particular expression translates efficiently depends on the provider and data type, so use a provider-supported range predicate for the actual benchmark.
The general benchmark should include:
Equality
Range
Prefix search where supported
Multiple predicates
Ordering
The exact SQL translation should always be verified.
Benchmark Ordering
Suppose the application uses:
var customers = await db.Customers
.Where(c =>
c.Region == region)
.OrderBy(c =>
c.Address.PostalCode)
.ToListAsync();
Now the benchmark should examine whether the chosen index supports the filtering and ordering pattern efficiently.
For example:
Query
↓
Filter Region
↓
Order PostalCode
A composite index may have a different execution profile from two separate indexes.
Again, measure the actual plan.
Measure Write Overhead
Indexes improve some reads by adding work to writes.
This is one of the most important production considerations.
Benchmark:
INSERT
UPDATE
DELETE
with and without the index.
For example:
db.Customers.AddRange(customers);
await db.SaveChangesAsync();
Measure:
| Operation | No Index | Complex-Type Index |
|---|
| Insert | Measure | Measure |
| Update | Measure | Measure |
| Delete | Measure | Measure |
| Query | Measure | Measure |
The correct architecture depends on the read/write ratio.
An index that substantially improves a critical query may be worthwhile even if writes become more expensive.
Test Update Patterns
Not every update affects the indexed property.
Compare:
Update Address.PostalCode
with:
Update Customer.Name
and:
Update unrelated property
If the indexed property changes, the database generally has more index-maintenance work to perform.
The benchmark should distinguish these cases.
Test Bulk Inserts
A single-row benchmark may not represent ingestion workloads.
Test batches such as:
1,000 rows
10,000 rows
100,000 rows
Then compare:
No index
against:
Complex-type index
Measure:
Total insert time
Rows/sec
CPU
Transaction duration
Storage growth
This is especially useful for applications that periodically import large datasets.
Measure Index Storage
Indexes consume storage.
For a large table, record:
Table size
Index size
Total database size
before and after adding the index.
Do not evaluate an index solely on query latency.
The complete trade-off is:
Read performance
+
Write cost
+
Storage
+
Maintenance
JSON-Mapped Complex Types
EF Core 11 also supports indexes over paths inside complex types mapped to JSON columns where the provider supports them. Microsoft documents JSON-path indexing for relational providers such as SQL Server and corresponding indexing configuration for Azure Cosmos DB.
For example:
modelBuilder.Entity<Customer>()
.ComplexProperty(
c => c.Address,
b => b.ToJson());
modelBuilder.Entity<Customer>()
.HasIndex("Address.PostalCode");
The actual index implementation depends on the provider.
This distinction is critical.
An EF Core model configuration does not imply that every database provider creates the same physical index structure.
Benchmark Relational Columns vs JSON
For a database that supports both approaches, compare:
Relational complex-type columns
against:
JSON-mapped complex type
Then benchmark:
Query
Insert
Update
Storage
For example:
| Storage Model | Read | Insert | Update | Storage |
|---|
| Relational columns | Measure | Measure | Measure | Measure |
| JSON column | Measure | Measure | Measure | Measure |
This can reveal an important architectural trade-off.
JSON storage may simplify certain modeling requirements, while relational columns may provide different indexing and query characteristics.
The correct choice depends on the workload.
Test Composite Queries at Scale
A realistic customer-search endpoint might contain:
var customers = await db.Customers
.Where(c =>
c.Region == region &&
c.Address.City == city &&
c.Address.PostalCode == postalCode &&
c.CreatedAt >= fromDate)
.OrderByDescending(c => c.CreatedAt)
.Take(50)
.ToListAsync();
This is more representative than a single-property benchmark.
The benchmark should compare different index strategies:
No index
PostalCode
Region + PostalCode
Region + City + PostalCode
Region + PostalCode + CreatedAt
The goal is not to create the largest possible index.
It is to determine which index matches the actual query workload.
Avoid Over-Indexing
A common mistake is creating an index for every frequently queried property.
Suppose an application has:
City
PostalCode
Region
Country
CreatedAt
Status
CustomerType
Creating an index on every column can increase:
Instead, identify high-value query patterns.
Use workload evidence.
Test Index Column Order
Consider:
.HasIndex(c =>
new
{
c.Region,
c.Address.PostalCode
});
Now compare:
.HasIndex(c =>
new
{
c.Address.PostalCode,
c.Region
});
The two indexes can produce different plans depending on the query predicates and database optimizer.
Benchmark:
Region only
PostalCode only
Region + PostalCode
PostalCode + Region
This is particularly important when one column has much higher selectivity than another.
Do not choose column order based solely on the order in which properties appear in the C# model.
Test Pagination
Many APIs query indexed properties and then paginate:
var customers = await db.Customers
.Where(c =>
c.Address.PostalCode == postalCode)
.OrderBy(c => c.Id)
.Skip(page * pageSize)
.Take(pageSize)
.ToListAsync();
Test several page positions:
Page 1
Page 10
Page 100
Page 1000
Offset pagination can become expensive for deep pages depending on the database and query.
If the application supports keyset pagination, compare that approach separately.
The benchmark should use the pagination strategy actually deployed.
Use Query Tags During Investigation
EF Core query tags can help connect application queries with database diagnostics.
For example:
var customers = await db.Customers
.TagWith("CustomerSearchByPostalCode")
.Where(c =>
c.Address.PostalCode == postalCode)
.ToListAsync();
This makes it easier to identify the query when examining database logs or monitoring systems.
During a benchmark, consistent query tagging can also make before/after comparisons easier.
Test Cold and Warm Cache
Database caching can dramatically affect results.
Run:
Cold-ish workload
and:
Warm workload
separately where the database platform and test environment allow meaningful control.
A query that reads data already present in memory can behave very differently from one that requires physical storage access.
Do not mix those measurements and call them equivalent.
Benchmark Under Concurrency
A single-user query benchmark is not enough for a production API.
Test concurrency:
1
10
50
100
250
500
At each level measure:
Throughput
p50
p95
p99
CPU
Reads
Connection usage
An index may provide a substantial benefit under concurrency because it reduces database work per request.
However, it can also increase write contention or consume additional resources.
Measure both read-heavy and write-heavy scenarios.
Compare Read-Heavy and Write-Heavy Workloads
Consider two applications.
Read-Heavy
95% reads
5% writes
Write-Heavy
30% reads
70% writes
These percentages are example benchmark profiles.
The optimal indexing strategy can differ significantly between them.
For each profile, measure:
Requests/sec
Query latency
Write latency
CPU
Storage
This prevents an index recommendation from being based on a single workload pattern.
Common Mistakes
Benchmarking Only 1,000 Rows
Indexes may show little difference at small scale.
Use production-like data volumes.
Measuring Only Query Time
Include reads, CPU, execution plans, and storage.
Assuming an Index Always Improves Performance
The database optimizer may choose a scan when that is cheaper.
Ignoring Writes
Every index has a maintenance cost.
Using the Wrong Provider
Provider behavior matters, especially for JSON-path indexes.
Comparing Different Datasets
Keep data distribution equivalent between benchmark runs.
Creating Extremely Wide Composite Indexes
An index should match important access patterns rather than every possible filter.
Troubleshooting
EF Core Creates the Index but the Query Is Still Slow
Inspect the database execution plan.
Check:
Selectivity
Statistics
Index column order
Returned row count
Sorting
Additional predicates
The optimizer may reasonably decide that the index is not beneficial.
The Index Is Not Being Used
Do not immediately force index usage.
First determine why.
If the query returns a large percentage of the table, a scan may genuinely be cheaper.
Inserts Became Slower
Measure index-maintenance overhead.
Check whether the new index is large or whether many rows are being inserted simultaneously.
JSON Indexing Behaves Differently Across Providers
That is expected.
EF Core provides provider abstractions, but the physical index implementation is database-specific. Microsoft explicitly notes that support for JSON-path indexes depends on the database provider.
Composite Index Does Not Improve Every Query
Check the leading columns.
An index designed for:
Region + PostalCode
does not necessarily provide the same benefit for every query involving only PostalCode.
Recommended Benchmark Matrix
For a serious EF Core 11 evaluation, use this matrix:
| Dimension | Scenarios |
|---|
| Data size | 100K / 1M / 10M |
| Selectivity | High / Medium / Low |
| Index | None / Single / Composite |
| Operation | Read / Insert / Update / Delete |
| Query | Equality / Range / Sort / Pagination |
| Concurrency | 1 / 10 / 100 / 500 |
| Storage | Relational / JSON where supported |
| Cache | Cold-ish / Warm |
| Metrics | Latency / Reads / CPU / Storage |
This produces enough information to make an architecture decision rather than a superficial performance claim.
Best Practices
Benchmark complex-type indexes against the same dataset without the index.
Use realistic data volumes.
Measure query selectivity.
Capture generated SQL.
Inspect actual execution plans.
Measure logical reads where supported.
Benchmark writes as well as reads.
Test composite index column order.
Measure index storage.
Test concurrency.
Test provider-specific behavior separately.
Evaluate JSON-path indexes independently from ordinary relational indexes.
Avoid creating indexes without a demonstrated workload.
Record database version, EF Core version, provider version, hardware, and configuration.
Repeat important measurements before drawing conclusions.
Frequently Asked Questions
Does EF Core 11 support indexes on complex-type properties?
Yes. EF Core 11 allows indexes over scalar properties nested inside non-collection complex types. Composite indexes can also combine entity properties with complex-type properties.
Can complex-type properties participate in composite indexes?
Yes.
For example:
modelBuilder.Entity<Customer>()
.HasIndex(c => new
{
c.Region,
c.Address.PostalCode
});
EF Core 11 documents this capability explicitly.
Does adding an index guarantee faster queries?
No.
The database optimizer chooses an execution strategy based on factors such as selectivity, statistics, available indexes, and estimated cost.
Do complex-type indexes increase write cost?
Potentially, yes.
The database must maintain the index when indexed values change and generally when rows are inserted or deleted. The magnitude of the overhead should be measured for the target database and workload.
Can complex types mapped to JSON be indexed?
EF Core 11 supports configuring indexes over JSON paths where the database provider supports them. Microsoft specifically documents provider-dependent JSON-path indexing behavior.
Should I use a composite index or several single-column indexes?
There is no universal answer.
Benchmark the actual query patterns. Composite indexes can be highly effective for multi-column predicates, but they also introduce additional storage and write-maintenance costs.
How much data should I use for the benchmark?
Use a dataset representative of the production workload.
If the production system contains millions of rows, a benchmark against a few thousand rows is unlikely to reveal the same indexing behavior.
Conclusion
EF Core 11 makes complex types more useful for database modeling by allowing keys and indexes to traverse nested scalar properties. Composite indexes can also combine ordinary entity properties with complex-type properties, while supported providers can expose indexing capabilities for JSON paths.
But the existence of the feature is only the beginning.
The important engineering question is whether the index improves the workload that matters.
A meaningful benchmark follows the complete path:
LINQ Query
↓
EF Core Translation
↓
Generated SQL
↓
Database Optimizer
↓
Execution Plan
↓
Reads / CPU
↓
Application Latency
And because indexes have costs, the benchmark should also examine:
Query improvement
+
Write overhead
+
Storage
+
Concurrency
The strongest conclusion is therefore not:
"EF Core 11 complex-type indexes are faster."
Instead, it should be:
"For this query pattern, dataset size, provider, and workload, this index changed the execution characteristics in this measurable way."
That distinction is critical for production database engineering.
EF Core 11 provides the capability to express indexes over complex-type properties. Whether those indexes belong in a production schema should be determined by query plans, realistic data, workload measurements, and the operational cost of maintaining them.