Chapter 04 · Keys, Indexes, Property Semantics, Value Generation, and Shadow State
Configure Indexes, Uniqueness, Sort Order, Filters, Included Columns, and Provider Differences
Design a small evidence-driven index portfolio with uniqueness, composite ordering, and provider-gated filters/includes instead of treating indexes as portable magic.
Learning outcomes
ServiceHub can now identify work orders correctly, but correctness alone does not make lookups efficient. Dispatch screens filter by priority and time, integrations search by external reference, and operators sort recent work. Indexes are physical database structures with write/storage costs, and EF's index model is only a configuration description until a provider turns it into DDL.
Configure single and composite indexes with HasIndex, uniqueness, database names, and per-column sort order.
Explain left-prefix/order/selectivity reasoning without promising that an index will always be used.
Differentiate an alternate key from a unique index and avoid redundant indexes.
Gate provider-specific features such as SQL Server filters and INCLUDE columns instead of pretending they are portable EF abstractions.
Verify SQLite index DDL and query-plan evidence with realistic ServiceHub predicates.
1. An index is an access path, not an automatic speed switch
An index stores key values in an order/structure the database can exploit for some predicates, joins, ordering, or uniqueness checks. Every additional index also consumes storage and adds maintenance work to inserts, deletes, and updates of indexed columns. Therefore “index every filter column” is not a production strategy.
| Question | Why it matters |
|---|---|
| What query shape is slow? | An index should support an observed workload, not an imagined column. |
| How selective is the predicate? | Low-selectivity predicates may still favor scans. |
| Does ordering match? | Composite key order and sort direction can affect whether sorting is avoided. |
| Which columns are updated? | Indexes amplify write work on changed key/include columns. |
| What does the real plan show? | The optimizer, statistics, data distribution, cache state, and provider determine actual use. |
2. Add a unique index for an integration reference—not an alternate key
ServiceHub receives an optional external ticket reference from partner systems. It must be unique when present, but no ServiceHub relationship should use it as principal identity. That is a unique-index use case rather than an alternate-key use case.
public string? ExternalReference { get; private set; }public void AttachExternalReference(string reference){ ExternalReference = string.IsNullOrWhiteSpace(reference) ? throw new ArgumentException("Reference is required.", nameof(reference)) : reference.Trim();}
builder.Property(x => x.ExternalReference) .HasColumnName("external_reference") .HasMaxLength(80);builder.HasIndex(x => x.ExternalReference) .IsUnique() .HasDatabaseName("UX_work_orders_external_reference");
SQLite permits multiple NULL values in a unique
index. Other database engines have their own null/unique
semantics and provider conventions. If your business rule says
“exactly one row may have null” or “nulls are not allowed,”
express that rule explicitly instead of assuming provider
behavior.
3. Composite indexes are ordered tuples
The dispatch queue often filters by priority and then presents
work-order numbers in descending sequence. For the mandatory
SQLite lab, use a composite index on
(Priority, WorkOrderNumber). This choice is
deliberate: the current SQLite provider can read/write
DateTimeOffset but does not natively translate
comparison/ordering over it. SQL Server and other providers have
different capabilities, so OpenedUtc-based ordering
is demonstrated only in provider-specific examples.
builder.HasIndex(x => new { x.Priority, x.WorkOrderNumber }) .IsDescending(false, true) .HasDatabaseName("IX_work_orders_priority_number");
The first column is ascending and the second descending in index metadata. Whether this exact ordering is honored/useful depends on the provider and engine. Do not quote “leftmost prefix” as a magical rule without inspecting the target database's planner and actual queries.
var queue = await db.WorkOrders .Where(x => x.Priority == WorkOrderPriority.High) .OrderByDescending(x => x.WorkOrderNumber) .Take(50) .Select(x => new { x.Id, x.WorkOrderNumber, x.CustomerName, x.OpenedUtc }) .ToListAsync(ct);
Later performance chapters will measure end-to-end behavior. Here the goal is simply to make index intent and generated SQL visible.
The Microsoft SQLite provider documents that equality over
DateTimeOffset is supported, but comparison and
ordering require client evaluation. Do not write a mandatory
server-side OrderBy(x => x.OpenedUtc) lab
against SQLite and then claim the provider translated it. If
production uses SQL Server, PostgreSQL, or another engine,
verify that provider separately.
4. Avoid redundant indexes created by identity constraints
An alternate key already creates the relational uniqueness
structure needed to enforce that key. Adding a second unique
index on the exact same WorkOrderNumber column
usually duplicates maintenance/storage without adding semantics.
builder.HasAlternateKey(x => x.WorkOrderNumber);// Usually redundant on the same property set:builder.HasIndex(x => x.WorkOrderNumber).IsUnique();
Inspect generated migrations and database catalogs before deciding two structures are necessary. Some engines implement constraints using indexes internally; others expose constraint/index metadata differently. The rule is not “constraints and indexes are identical,” but “do not create duplicate physical work without evidence.”
5. Provider-specific filters and included columns must be gated
SQL Server can use filtered indexes and non-key included columns. EF's SQL Server provider exposes convenient extensions for those capabilities. The mandatory course lab remains SQLite, so these snippets are explicitly provider-specific and must not be copied into a supposedly portable mapping layer without a provider boundary.
// Requires Microsoft.EntityFrameworkCore.SqlServer.// Illustrative provider-specific configuration; not used by the SQLite lab.builder.HasIndex(x => new { x.Priority, x.OpenedUtc }) .HasDatabaseName("IX_work_orders_open_queue") .HasFilter("[closed_utc] IS NULL") .IncludeProperties(x => new { x.WorkOrderNumber, x.CustomerName });
HasFilter contains SQL text and therefore is
provider/database syntax. IncludeProperties is a
SQL Server provider extension. PostgreSQL, MySQL/MariaDB,
Oracle, and SQLite have different capabilities and syntax.
Chapter 21 builds a formal cross-provider test matrix.
6. Inspect index metadata before touching the database
var entity = db.Model.FindEntityType(typeof(WorkOrder))!;foreach (var index in entity.GetIndexes()){ Console.WriteLine(index.GetDatabaseName()); Console.WriteLine($" Unique: {index.IsUnique}"); Console.WriteLine($" Properties: {string.Join(", ", index.Properties.Select(p => p.Name))}"); Console.WriteLine($" Descending: {string.Join(", ", index.IsDescending ?? Array.Empty<bool>())}"); Console.WriteLine($" Filter: {index.GetFilter() ?? "<none>"}");}
Runtime metadata proves what EF intends to ask the provider to create. It does not prove the database has already applied the migration, nor that the optimizer will choose the index for a query.
7. Inspect SQLite's actual index catalog
SELECT name, sqlFROM sqlite_schemaWHERE type = 'index' AND tbl_name = 'work_orders'ORDER BY name;
For deeper inspection, SQLite exposes pragmas such as
PRAGMA index_list('work_orders') and
PRAGMA index_xinfo('index_name'). Those commands
are database evidence, separate from EF metadata.
EXPLAIN QUERY PLANSELECT work_order_id, work_order_number, customer_name, opened_utcFROM work_ordersWHERE priority = 2ORDER BY work_order_number DESCLIMIT 50;
Do not manufacture a claim like “the query is 40% faster” from this lab. Dataset size, distributions, SQLite statistics, cache state, machine, query shape, and write load all matter. Record the plan text and explain what it shows.
8. Deliberately wrong approach: build an index portfolio from property names
A common ORM anti-pattern is to scan entity properties and
create indexes for anything ending in Id,
Date, or Status. That can produce a
large write-amplification tax while still missing the composite
query shape that actually matters.
Start from workload evidence: generated SQL, query frequency, database plans, data distribution, and write rate. Configure only the indexes justified by those observations, then test against the production-like provider. EF metadata is the deployment mechanism, not the tuning oracle.
9. Hands-on lab: create and prove a small index portfolio
-
Add optional
ExternalReferenceand its unique index. -
Add the
(Priority, WorkOrderNumber)composite index with explicit sort order for the SQLite lab. -
Do not add a duplicate unique index on
WorkOrderNumberbecause it is already an alternate key. -
Generate
Chapter04Indexesand inspect everyCreateIndex/AddUniqueConstraintoperation. - Apply only to the disposable SQLite database.
-
Inspect
sqlite_schemaandPRAGMA index_xinfo. - Seed enough rows to make the query shape meaningful; record exact row count/distribution if you compare plans.
-
Run
EXPLAIN QUERY PLANfor the priority/recent query before and after the composite index in separate resettable database states. - Keep SQL Server filter/include configuration only as a documented provider-specific variant.
Verification checklist
- Index names are stable and descriptive.
- The alternate-key property is not redundantly indexed without justification.
- SQLite catalog output matches EF migration intent.
- Any query-plan claim includes the exact query and lab conditions.
- No SQL Server-only extension exists in the mandatory SQLite path.
Check your understanding
- When should a unique index be preferred over an alternate key?
- Why can two single-column indexes differ from one composite index?
- Does defining an index guarantee the optimizer uses it?
- Why is IncludeProperties not a provider-neutral assumption?
- What evidence distinguishes EF index intent from actual database state?
Review the answers
When you need uniqueness/access behavior but do not need the value to be an EF principal key.
A composite index has an ordered multi-column key and can support combined filtering/ordering in ways independent single-column indexes may not.
No. The optimizer chooses based on query shape, statistics, data distribution, costs, and engine behavior.
It is exposed by provider-specific functionality such as SQL Server and is not supported identically across databases.
Inspect IModel/migrations for EF intent and the target database catalog/DDL for applied physical state.
10. Production judgment and bridge
Indexes should be few enough to justify their write cost and specific enough to support observed workloads. Keep provider-specific index features behind explicit provider boundaries, test null/unique semantics on the real engine, and pair EF logs with database plans before making tuning claims.
Lesson 3 shifts from indexes to value ownership: which values
the application supplies, which EF temporarily assigns, and
which the database generates on insert/update. That distinction
determines generated SQL and what values EF expects back after
SaveChanges.
Authoritative references
- Indexes - EF Core — single/composite indexes, uniqueness, names, sort order, filters, and included columns
- Efficient Querying - EF Core — index-aware query design and database-plan evidence
- SQL Server Indexes - EF Core provider — SQL Server-specific index configuration
- SQLite Query Planner — SQLite planner/index background for the local lab
- Microsoft.EntityFrameworkCore.Sqlite 10.0.11 — current SQLite provider checkpoint