Why Won't PostgreSQL Use My Covering Index?
Dear Postgres, Why won’t you use my covering index?
Lately I’ve been learning to tune queries running against PostgreSQL, and it’s …
Read MoreWhenever you’ve got a new feature, one of the first things to ask is, “What happens when I break it?”
Because we’re going to break stuff.
With 2016 Query Store, a natural question is, “What happens if I force an execution plan, and that plan is no longer valid?” We’ll take a tour in some detail, and then I’ll sum up all the takeaways in a nice little list at the bottom of the post.
I’ve got a stored procedure, dbo.NameCountByGender. You give it a first name, and it returns a count of people with that name grouped by Gender.
I turned on Query Store, and it observed that I’ve had two different execution plans for the NameCountByGender procedure. Plan 2 has a higher average duration than plan 1.

I use comic sans for Query Store because it’s a birthday party for execution plans.
Plan 2 (the purple dot) has an average duration of 3 seconds, conveniently noted as 3000000 microseconds. Plan 1 has an average duration of … a lot less. After squinting I think that’s 500 milliseconds.
Brief aside: are commas really expensive? Or are they just not cool?
Query Store is all about bossing things around. As soon as we click on that blue dot, it’s right there with a ‘Force Plan’ button.

I click it, and it makes sure I really meant to do that. Which is a good thing, because I click a lot.

I tell it to force the plan, and we’re in business. That nested loop plan is here to stay!
Until I break it. You see how it’s doing a nice index seek at the top of the plan? We’re going to change that index.
I’m going to leave the index in place, with its current name, and just add a column to it. A column that, not coincidentally, could actually be used by our query to avoid that key lookup.
Here’s the code I use– it’s just CREATE INDEX WITH DROP_EXISTING=ON
CREATE NONCLUSTERED INDEX ix_FirstNameByYear_FirstName ON
dbo.FirstNameByBirthDate_1980_1989 (FirstName ASC) INCLUDE (Gender)
WITH (DROP_EXISTING=ON);
GO
After modifying the index, something really cool happens.
My stored procedure still works. It doesn’t fail! It can’t use the frozen query plan, so it generates a new plan (and actually figures out it can use the modified index better).

You can query all your forced plans at any time, and figure out if and why any are failing with this query (you might want to adjust the timezones)
SELECT
qsp.plan_id,
qsp.query_id,
OBJECT_NAME(qsq.object_id) as containing_object_name,
qsqt.query_sql_text,
force_failure_count,
last_force_failure_reason_desc,
qsp.count_compiles,
qsp.last_compile_start_time AT TIME ZONE 'Pacific Standard Time' AS last_compile_PST,
qsp.last_execution_time AT TIME ZONE 'Pacific Standard Time' AS last_execution_PST,
qsp.avg_compile_duration/1000. as avg_compile_milliseconds
FROM sys.query_store_plan AS qsp
LEFT JOIN sys.query_store_query AS qsq on qsp.query_id = qsq.query_id
LEFT JOIN sys.query_store_query_text AS qsqt on qsq.query_text_id=qsqt.query_text_id
WHERE is_forced_plan = 1;
GO
Here’s our failed friend:

I revert my change with the following code. This just removes the included column and puts the index back to where it was when the plan was forced.
CREATE NONCLUSTERED INDEX ix_FirstNameByYear_FirstName ON
dbo.FirstNameByBirthDate_1980_1989 (FirstName ASC)
WITH (DROP_EXISTING=ON);
GO
You guys aren’t going to believe this. IT WORKS AGAIN. I was so surprised, I tested it multiple times. The plan is sucessfully forced if you revert the index back to the old definition. Here’s confirmation in the execution plan properties that the plan was forced:

While the index is dropped, the plan fails to be forced, but you do get an alternate last_force_failure_reason_desc of “NO_INDEX”.
If you create the index again with the correct definition, the plan can be successfully forced again. Here’s what a forced plan looks like in the Query Store Top resource consumers report:

Pulling this all together, here’s the facts as I see them in 2016 RC1:
Copyright (c) 2025, Catalyze SQL, LLC; all rights reserved. Opinions expressed on this site are solely those of Kendra Little of Catalyze SQL, LLC. Content policy: Short excerpts of blog posts (3 sentences) may be republished, but longer excerpts and artwork cannot be shared without explicit permission.