
There’s a moment every DBA and developer hits eventually: a query is slow, someone pulls up the execution plan, and it’s just… a wall of boxes and arrows that might as well be hieroglyphics. You know the answer is in there somewhere. You just don’t know where to look.
This guide is meant to close that gap. Not a glossary of operator names — a genuine, practical walkthrough of how to read a plan, where the real bottlenecks hide, and what to actually do about them. Everything here applies whether you’re on-prem SQL Server, Azure SQL Database, or Azure SQL Managed Instance — the engine reading the plan is fundamentally the same, even though a couple of the diagnostic tools around it differ slightly by platform, which I’ll call out as we go.
Let’s start at the beginning.
What an execution plan actually is
When you run a query, SQL Server doesn’t just “do what you asked” in the order you wrote it. The query optimizer looks at your query, considers the available indexes, statistics, and several candidate strategies for retrieving the data, estimates the cost of each, and picks the one it believes is cheapest. The execution plan is that chosen strategy, made visible — which indexes get used, what order tables get joined in, whether the engine scans or seeks, and what extra work (sorting, filtering, aggregating) happens along the way.
Reading a plan is really just asking the optimizer to show its work.
Estimated vs. Actual — and why the difference matters
You can view a plan two ways:
- Estimated Execution Plan — what the optimizer predicts will happen, generated without actually running the query. Fast to get, useful for a quick look, but it’s a prediction, not a measurement.
- Actual Execution Plan — generated after the query runs, including real row counts and runtime statistics at every step.
Here’s the habit worth building early: always look at the actual plan when you’re genuinely troubleshooting. The estimated plan tells you what the optimizer thought would happen. The actual plan tells you what really happened — and the gap between those two numbers is often the entire root cause of a performance problem, as we’ll get into shortly.
In SSMS or Azure Data Studio, this is a toolbar button or a keyboard shortcut (“Include Actual Execution Plan” before running, versus “Display Estimated Execution Plan” without running).
How to actually read a plan
A graphical execution plan flows in a specific direction — in SSMS’s default layout, data flows right to left, bottom to top. The rightmost, bottom operators are where data access begins (reading from a table or index); the leftmost, topmost operator is the final result being returned to you.
Two visual cues do most of the heavy lifting when you’re scanning a plan for the first time:
Arrow thickness. The connecting lines between operators are proportional to the number of rows flowing through them. A thick arrow means a lot of data is moving through that part of the plan — often your fastest visual cue for where the bulk of the work is happening, before you’ve read a single tooltip.
Cost percentage. Each operator shows a relative “Query Cost” percentage — the optimizer’s estimate of how much of the total query cost that operator represents. It’s a useful first-pass guide, but remember: it’s the optimizer’s model, not a direct measurement of actual elapsed time. Don’t treat it as gospel — treat it as a place to look first.
My actual habit: start on the right side, follow the thickest arrows and the highest cost percentages, and that’s usually where 80% of a plan’s story lives
The operators you’ll see constantly
You don’t need to memorize fifty operator types. You need to genuinely understand about a dozen, because they show up in almost every plan you’ll ever look at.
Scans and Seeks
- Table Scan / Clustered Index Scan — reads every row. Not automatically bad (a small table or a query genuinely needing most rows can make a scan the cheapest option), but on a large table with a selective filter, it’s usually a sign the right index doesn’t exist.
- Index Seek — jumps directly to matching rows using an index’s sorted structure. Generally the outcome you want for a selective query, though a seek returning a huge number of rows, or one executed many times, still isn’t automatically cheap.
Key Lookup (Bookmark Lookup)
Shows up when a non-clustered index seek finds the right rows but doesn’t contain every column the query needs, forcing a separate trip back to the clustered index for each matching row. On a query returning many rows, this is a lot of extra random I/O — one of the most common, most fixable performance issues you’ll encounter, usually solved with a covering index.
Joins: Nested Loops, Hash Match, Merge Join
- Nested Loops — iterates one input, probing the other (ideally via an efficient seek) for each row. Cheap when one side is small or well-indexed; degrades badly if the outer input turns out much larger than expected.
- Hash Match — builds an in-memory hash table from the smaller input, then probes it with the other. Good for larger, unindexed inputs, but requires a memory grant — insufficient memory here causes a spill to tempdb, a real and common performance killer.
- Merge Join — walks two already-sorted inputs together in one efficient pass. Very cheap when both inputs are already sorted on the join key; otherwise the plan needs an extra Sort operator feeding into it, which can offset the benefit.
Sort
Reorders rows for ORDER BY, GROUP BY, or a join that needs sorted input. Expensive specifically when the row set is large and doesn’t fit in the memory granted for the operation — watch for a spill warning here.
Filter
Applies a predicate to rows after they’ve already been read — often a sign the WHERE clause condition wasn’t sargable enough for the chosen index to filter during the seek itself, meaning more rows were read than strictly necessary.
Compute Scalar
Evaluates an expression per row — usually cheap on its own, but worth noticing if it wraps a column being compared in a WHERE clause, since that pattern can silently prevent an index seek on that column entirely.
Spool (Eager Spool / Lazy Spool)
Stores an intermediate result set in tempdb for reuse later in the plan. Sometimes a legitimate optimization; sometimes a sign of an inefficient plan shape or a correctness mechanism (like the Halloween Protocol on certain UPDATE statements) worth understanding rather than reflexively distrusting.
Spotting bottlenecks: the actual diagnostic process
Here’s the sequence I genuinely follow, matching the flowchart above.
Step 1 — Capture the actual plan, not just the estimated one
You need real row counts to diagnose anything meaningfully. This is non-negotiable for real troubleshooting.
Step 2 — Check warning icons first
A yellow warning triangle on an operator is the engine explicitly flagging something worth your attention — most commonly:
- A tempdb spill on a Sort or Hash Match (insufficient memory grant)
- An implicit conversion warning (a data type mismatch silently preventing an index seek)
- A missing statistics warning
- A “no join predicate” warning (often an accidental cross join)
These are the fastest, highest-signal thing to check in an unfamiliar plan — start here before reading anything else.
Step 3 — Compare actual vs. estimated row counts
Click into an operator’s properties (or hover the tooltip) and look at “Actual Number of Rows” versus “Estimated Number of Rows.” A significant mismatch — especially one that cascades and gets worse downstream — is one of the single most common root causes of a bad plan. It usually traces back to one of:
- Stale statistics
- A non-sargable predicate the optimizer can’t estimate accurately
- Parameter sniffing (a plan compiled for one parameter value, reused for a very different one)
Step 4 — Find the costliest operator
Follow the thick arrows and high cost percentages. This tells you where to focus, not yet why it’s expensive.
Step 5 — Diagnose the actual root cause
This is where the specific operator type and the surrounding context matter. A few classic patterns:
- Scan where you’d expect a seek → missing index, or a non-sargable predicate preventing seek usage on an existing index
- Key Lookup on a query returning many rows → the non-clustered index isn’t covering; add the missing columns as included columns
- Hash Match with a spill warning → memory grant was too small for the actual data volume; often tied to a cardinality estimation error upstream
- Nested Loops with a huge “Number of Executions” on the inner side → the inner seek is individually cheap but executed so many times it dominates total cost
- Sort with a spill → same story as Hash Match — check the memory grant and the row estimate feeding into it
Step 6 — Apply the narrowest fix, then retest
Compare the before-and-after plan directly. Don’t assume a fix worked because the query “feels faster” — confirm the specific problem operator is actually gone or reduced.
Finding costly queries in the first place
Before you can read a specific plan, you need to know which query deserves the attention. A few reliable ways to find that:
Query Store (the modern standard, on by default in Azure SQL Database). The “Top Resource Consuming Queries” report shows exactly what’s driving load over a chosen time window — no guessing required.
SELECT TOP 10 qs.total_worker_time / qs.execution_count AS avg_cpu,
qs.execution_count, t.text
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) t
ORDER BY avg_cpu DESC;
Query Store’s regression detection. If a query used to be fast and now isn’t, Query Store’s runtime stats let you compare its current plan and performance directly against its own historical baseline — turning “it feels slower” into a provable, timestamped fact.
On Azure SQL Database specifically, sys.dm_db_resource_stats is worth checking before diving into any single query — it tells you whether there’s genuine, database-wide resource pressure (CPU, I/O, memory) at all, so you’re not chasing a query-level fix for what’s actually a capacity problem.
Common patterns that quietly wreck performance
A handful of anti-patterns show up over and over. Worth knowing them by sight.
Non-sargable predicates
Wrapping an indexed column in a function or an implicit conversion prevents the optimizer from seeking on it at all, even if the index exists.
-- Non-sargable — forces a scan even with an index on OrderDate
WHERE CONVERT(varchar, OrderDate, 101) = '01/15/2026'
-- Sargable — can seek
WHERE OrderDate >= '2026-01-15' AND OrderDate < '2026-01-16'
Implicit conversions
Comparing an int column to a string literal, or an nvarchar column to a varchar value, forces the engine to convert one side for every row — often silently disabling a perfectly good index. The fix is usually matching the literal’s data type to the column’s, or explicitly casting the literal, not the column.
Parameter sniffing
A cached plan compiled for the first parameter value used gets reused for later, very different values — great for one case, terrible for another. You’ll see it as a query with wildly inconsistent performance depending on which parameter triggered the compile. Mitigations include OPTION (RECOMPILE) for genuinely volatile queries, OPTIMIZE FOR a representative value, or letting Azure SQL’s Automatic Tuning FORCE LAST GOOD PLAN catch and revert a regression automatically.
Missing or overly-wide indexes
A missing index forces a scan; too many overlapping indexes slow down every write without a proportional read benefit. sys.dm_db_missing_index_details gives you the engine’s own suggestions — a good starting point, not gospel, since it doesn’t account for write overhead or redundancy with existing indexes.
SELECT *
Beyond the general hygiene argument, SELECT * defeats covering indexes — if a query only needs three columns but selects all twenty, no non-clustered index can realistically cover it, forcing a key lookup (or a full clustered index scan) that a narrower SELECT list could have avoided entirely.
Optimization techniques that actually work
Roughly in the order I reach for them — cheapest and least invasive first:
- Add or adjust an index. Usually the highest-leverage, lowest-risk fix. A covering index that eliminates a key lookup is often the single biggest win available for the least effort.
- Update statistics. If actual and estimated row counts diverge significantly and the data has changed a lot recently, this is a cheap thing to rule out before assuming you need a structural fix.
- Rewrite the predicate to be sargable. Fixing a non-sargable
WHEREclause often turns a scan into a seek without touching indexes at all. - Address parameter sniffing specifically, once you’ve confirmed that’s genuinely the cause (via comparing plans across different parameter values).
- Reconsider the query’s logical structure — an accidental cross join, an unnecessarily wide
SELECT, or a subquery that could be a more efficient join. - Only then, consider a query hint — and treat it as a targeted, documented exception, not a default tool, since a hint can lock in behavior that stops being correct as data changes.
A worked example
Here’s a genuinely common scenario, walked through the way you’d actually diagnose it.
The complaint: “This report used to load instantly. Now it takes 8 seconds.”
Step 1 — Actual plan, not estimated. Confirmed: an actual plan run against today’s data.
Step 2 — Warning icons. A yellow triangle on a Sort operator: “Operator used tempdb to spill data.”
Step 3 — Actual vs. estimated rows. The Sort’s estimated row count was 1,200. Actual was 340,000. A massive miss.
Step 4 — Costliest operator. The Sort itself, and the Clustered Index Scan feeding it — both carrying the thick arrows.
Step 5 — Root cause. Tracing back from the Sort, the underlying scan was against a table whose statistics hadn’t updated in weeks despite heavy daily inserts — the row estimate was based on a stale, much smaller historical snapshot. That bad estimate led to an undersized memory grant for the Sort, which spilled to tempdb disk under the real data volume.
Step 6 — Fix and retest. Updated statistics on the table, reran the query. The new plan’s estimate matched reality far more closely, the memory grant sized correctly, the spill disappeared, and the query dropped from 8 seconds to under 300ms. Compared the before/after plans directly to confirm — no more spill warning, no more Sort as the dominant cost.
That’s the whole method, applied end to end: confirm, localize, hypothesize, validate, fix, verify.
Platform-specific notes
On-prem SQL Server gives you the full toolkit — Extended Events, sys.dm_os_wait_stats since last restart, full server-level DMV access.
Azure SQL Database has Query Store on by default, which is genuinely your best friend here — you get historical plan and performance tracking without any setup. You don’t get OS-level access or some server-scoped DMVs, but sys.dm_db_resource_stats and sys.dm_exec_requests cover the vast majority of what you’d actually need for query-level troubleshooting.
Azure SQL Managed Instance sits in between — closer to full SQL Server DMV/feature parity than Azure SQL Database, while still being a managed platform with Query Store available and encouraged the same way.
The reading technique — arrows, cost percentages, operator diagnosis — is identical across all three. Only the surrounding tooling for finding what to look at shifts slightly by platform.
A practical checklist to keep nearby
- Am I looking at the actual plan, not just estimated?
- Any warning icons on any operator?
- Do actual vs. estimated row counts diverge significantly anywhere?
- Where are the thickest arrows and highest cost percentages?
- Is there a Key Lookup that a covering index could eliminate? Any non-sargable predicates or implicit conversions hiding in the
WHEREclause? - Does the query’s performance vary a lot by parameter value (parameter sniffing)?
- After a fix, did I compare the plan before and after — not just assume it worked?
That last one is the habit that separates people who genuinely get good at this from people who stay stuck guessing — always close the loop and confirm, with the same tool you used to find the problem, that your fix actually solved it.
Read more articles on Execution Plans
SQL Server Execution Plans Explained: A Beginner’s Guide for DBAs and Developers
Top 50 Azure SQL Execution Plan Interview Questions and Answers (Beginner to Advanced)
Difference between Actual & Estimated Execution Plan
How to resolve multiple execution plans cache issue?
Execution Plan Analysis: CTEs vs Temp Tables vs Derived Tables
For Interview Questions on SQL SQL Server, Azure SQL, Performance Tuning, Security, and DBA, click the link below:-
https://www.techmixing.com/interview-questions-2
Explore the Complete TechMixing Article Sitemap – Click the Link Below
https://www.techmixing.com/site-map
Discover more from Technology with Vivek Johari
Subscribe to get the latest posts sent to your email.



