Skip to main content
ANVISoftware Solutions
Lesson 15 of 17Advanced16 min

Indexes

By the end of this lesson

Add indexes that support your real queries.

An index is a separate, sorted structure that the database maintains alongside a table so that it can find rows without reading all of them. Looking up a surname in an alphabetical list of ten thousand names does not require reading ten thousand names.

Without a usable index the database reads the whole table, which is called a scan. With one it goes more or less straight to the rows it wants, which is called a seek. On a thousand rows you cannot tell the difference. On a million rows you can tell from across the room.

Indexes belong to the database, but you declare them in your model so that migrations create them. That keeps the schema and the code that depends on it in one place, under version control.

Declaring indexes in OnModelCreating
C#
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // Supports: WHERE CustomerId = @id ORDER BY OrderDate DESC
    modelBuilder.Entity<Order>()
        .HasIndex(o => new { o.CustomerId, o.OrderDate });

    // A business rule, enforced by the database rather than by hope.
    modelBuilder.Entity<Product>()
        .HasIndex(p => p.Sku)
        .IsUnique();

    // Only the rows anything queries. Smaller index, cheaper writes.
    modelBuilder.Entity<Order>()
        .HasIndex(o => o.Status)
        .HasFilter("[Status] <> 3");

    modelBuilder.Entity<Employee>()
        .HasIndex(e => e.DepartmentId)
        .HasDatabaseName("IX_Employees_DepartmentId");
}
  • HasIndex takes the columns. An anonymous object with several properties declares a composite index, and the order you write them in is significant — the next section is about why.
  • IsUnique makes the index a constraint as well, so a duplicate SKU fails with a DbUpdateException. That is the behaviour you want for a value that must be unique, because the guarantee then holds regardless of which code path inserts the row.
  • HasFilter writes a filtered index, using the database's own SQL for the predicate. Here status 3 is Cancelled, and nothing in the application queries cancelled orders by status.
  • HasDatabaseName only changes the name. EF Core generates a sensible one, so reach for this when a naming convention exists that you have to match.
  • The [Index] attribute does the same job from the entity class. Configuration in OnModelCreating keeps persistence concerns out of the entity type, which matters more as the model grows.
What the migration puts in the database
SQL
CREATE INDEX [IX_Orders_CustomerId_OrderDate]
    ON [Orders] ([CustomerId], [OrderDate]);

CREATE UNIQUE INDEX [IX_Products_Sku]
    ON [Products] ([Sku]);

CREATE INDEX [IX_Orders_Status]
    ON [Orders] ([Status])
    WHERE [Status] <> 3;
  • Nothing exotic. An index declared in the model becomes an ordinary CREATE INDEX in the migration, and you can read it there before it runs anywhere.
  • Review that file every time. A migration is where you find out that an index you assumed existed does not, or that one you did not intend is about to be created on a table with fifty million rows.

A composite index is sorted by its first column, then by the second within that. That one fact decides which queries it can help. Take IX_Orders_CustomerId_OrderDate and two queries:

 WHERE CustomerId = 12 AND OrderDate >= @fromWHERE OrderDate >= @from
Is the leading column in the predicate?Yes — CustomerId is first in the indexNo — the query filters only on the second column
How the database finds the rowsSeeks to that customer's entries, which are already in date orderScans the table, or scans the whole index, and filters as it goes
As the table growsWork stays roughly proportional to the rows returnedWork grows with the rows stored
What to do about itNothing — this is the query the index was forReorder to (OrderDate, CustomerId), or add a second index and pay for it on every write

Summary

  • An index lets the database find rows without reading the table, turning a scan into a seek
  • Declare indexes in the model so migrations create them and the schema stays under version control
  • An index only helps a query whose predicate matches it, starting with its leading column
  • Composite column order is the detail most often got wrong, and it is invisible in the model
  • Every index costs write performance and storage, so add one for a measured query rather than on principle

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Work backwards from your queries

Write down the three or four queries your order list and order detail screens actually run, including their WHERE and ORDER BY clauses.

For each one, name the index that would support it, with the columns in order.

Then check your migrations and see which of those indexes exist.

Show solution

The order matters: predicate columns first, then columns used for sorting. An index on (Status, OrderDate) serves a query that filters on status and sorts by date, because the rows for one status are already in date order.

Doing it in this direction — queries first, indexes second — is the whole point. Indexes designed from the table structure tend to cover columns that look important rather than columns that are filtered.

Expect to find at least one index EF Core created for a foreign key that you were about to add by hand, and at least one query with no support at all.

Think about it

Twelve indexes and slow inserts

A busy Orders table has twelve indexes and insert performance has become a problem.

How would you decide which ones to remove, and which ones can never be removed regardless of usage?

Show solution

Start with usage statistics from the database: an index with no reads and many writes is costing you on every insert and returning nothing.

Then look for redundancy. An index on (CustomerId) is largely covered by one on (CustomerId, OrderDate), because the second is sorted by CustomerId first. The narrower one can often go.

Unique indexes stay, even if they are never used for reading. They are enforcing a rule, and dropping one changes what the database allows rather than only how fast it is.

Then measure. Drop candidates on a copy with realistic data, run the real read paths, and check nothing regressed before doing it for real. An index that statistics call unused may be the one that keeps a monthly report from timing out.

Saved in this browser only.