You added a second HasIndex(a => a.StartsAt) and dotnet ef migrations add produced a
RenameIndex instead of a CreateIndex. EF Core did not see two indexes; it saw two
configurations of one.
An unnamed index in EF Core is identified by its property list, so calling HasIndex twice on the
same property returns the same index builder and the second call's settings overwrite the first's. Give each
call a model name — HasIndex(a => a.StartsAt, "name") — and they become two indexes. Pin
HasDatabaseName as well, or a naming-convention package will rename them for you.
The error
There is no error; there is a migration that does less than you asked. This is the real one, generated after the hardening pass:
// Migrations/20260809080602_HardeningPass.cs
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameIndex(
name: "ix_appointments_starts_at",
table: "appointments",
newName: "ix_appointments_starts_at_all");
migrationBuilder.AddCheckConstraint(/* ... */);
}
The context had a unique, filtered index on StartsAt for the double-booking rule, and a new plain
index on StartsAt for all-statuses day queries. The migration shows a rename and no creation.
Why it happens
HasIndex(a => a.StartsAt) means "the index over StartsAt". Say it twice and EF returns the same
index both times. The first call set IsUnique() and a filter; the second only set a database name —
so the partial unique index quietly took the new name, and the plain range index the staff pages needed never
existed. EXPLAIN kept showing a sequential scan, and the DbContext looked perfectly correct.
Naming the indexes in the model is the fix, but there was a second trap: EFCore.NamingConventions
rewrites model-level names to snake_case, and the first attempt came out as ix_appointments_starts_at1.
A pinned HasDatabaseName is the only name the package cannot touch.
The fix
// src/ClinicLive/Data/ApplicationDbContext.cs
// NOTE the explicit name: EF identifies an index by its property list, so
// two HasIndex(a => a.StartsAt) calls silently MERGE into one — our first
// hardening attempt renamed this index instead of adding a second one.
e.HasIndex(a => a.StartsAt, "ix_appointments_slot_active_unique")
.IsUnique()
.HasFilter("status NOT IN ('Cancelled', 'NoShow')")
.HasDatabaseName("ix_appointments_slot_active_unique");
e.HasIndex(a => a.StartsAt, "ix_appointments_starts_at_all")
.HasDatabaseName("ix_appointments_starts_at_all");
The next migration, SplitSlotIndexes, renamed the partial index back and finally created the second one. Then verify in the database, not in C#:
SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'appointments';
Where it bit us
Season one, Part 11, the hardening pass — the part whose whole
job was to find what was missing. The index it claimed to add was caught by a reviewer after tag
part-11 and fixed in commit d9c5ba6, "Fix Part 11's index that never was", in
the repo. The lesson in that commit's own words: even
the hardening pass ships bugs; review the reviewer. In practice: read the migration EF generated, not the
DbContext you wrote, and check pg_indexes before you believe either.
Frequently asked
- Why does EF Core merge two HasIndex calls on the same property?
- EF Core identifies an unnamed index by the list of properties it covers. A second HasIndex call with the same properties returns the existing index builder rather than creating a new one, so its settings overwrite the first call's. Pass a name as the second argument to HasIndex to define a separate index.
- Why did my index end up named ix_table_column1?
- EF Core appends a number to make a duplicate name unique, and naming-convention packages such as EFCore.NamingConventions rewrite model-level names. Call HasDatabaseName with the exact name you want on each index so the generated migration uses it.
- How do I check that an index really exists in PostgreSQL?
- Query the pg_indexes view, for example select indexname, indexdef from pg_indexes where tablename = 'appointments'. It shows every index on the table with its full definition, including uniqueness and any WHERE filter, which is the ground truth a DbContext cannot give you.
More decoded errors in the Fixes category; the schema this came from starts at From Prompt to Production, Part 1.