Finding Bottlenecks in SQL Server XML and Graphical Execution Plans
A 40-operator plan is hard to scan visually. Every graphical plan is backed by XML you can query directly — genuinely useful once plans get wide.
Getting the Raw XML
SET SHOWPLAN_XML ON;
GO
SELECT * FROM dbo.OrderLog o JOIN dbo.Customer c ON o.customer_id = c.customer_id;
GO
SET SHOWPLAN_XML OFF;
-- Or right-click a graphical plan in SSMS -> "Show Execution Plan XML"
Searching for Specific Problems
Once you have the XML, search (Ctrl+F in SSMS’s XML view, or programmatically) for these telltale strings:
Search for
Finds
PlanAffectingConvert
Implicit conversions that changed the plan — a direct hit for the SARGability issue from Module 3
NoJoinPredicate
An accidental CROSS JOIN — often a missing join condition bug
SpillToTempDb
A Hash Match or Sort that ran out of memory and spilled to disk — a serious performance red flag
ColumnsWithNoStatistics
Columns the optimizer had no statistics for at all
Finding This Programmatically Across the Plan Cache
This CROSS APPLY pattern is the exact same tool from the Developers & DBAs course’s window-functions chapter, now aimed at sys.dm_exec_cached_plans instead of a business table — the same skill, a new target.
SELECT TOP 20 qp.query_plan, st.text
FROM sys.dm_exec_cached_plans cp
CROSS APPLY sys.dm_exec_query_plan(cp.plan_handle) qp
CROSS APPLY sys.dm_exec_sql_text(cp.plan_handle) st
WHERE CAST(qp.query_plan AS NVARCHAR(MAX)) LIKE '%SpillToTempDb%';
This finds every cached plan currently spilling to tempdb — a genuinely powerful way to proactively hunt for memory-pressure problems across an entire server, not just one query you’re already suspicious of.
Enjoyed this?
Subscribe to get every new lesson as soon as it’s published, and share it with a developer who’d find it useful.
Capturing SQL Server Deadlock Graphs from system_health: A Real Diagnostic Walkthrough
You already know what a deadlock is. Here’s how to actually retrieve the deadlock graph after the fact — without having set up a trace in advance — because system_health is running by default on every SQL Server instance.
Pulling Deadlock Graphs You Never Explicitly Captured
SELECT CAST(event_data.value('(event/data/value)[1]', 'VARCHAR(MAX)') AS XML) AS deadlock_graph,
event_data.value('(event/@timestamp)[1]', 'DATETIME2') AS event_time
FROM (
SELECT XEventData.query('.') AS event_data
FROM (
SELECT CAST(target_data AS XML) AS TargetData
FROM sys.dm_xe_session_targets st
JOIN sys.dm_xe_sessions s ON s.address = st.event_session_address
WHERE s.name = 'system_health' AND st.target_name = 'ring_buffer'
) AS Data
CROSS APPLY TargetData.nodes('RingBufferTarget/event[@name="xml_deadlock_report"]') AS XEventData(XEventData)
) AS tab(event_data);
Because system_health runs continuously by default, this query can retrieve deadlocks that happened before you even knew there was a problem — no advance trace setup required.
Reading the Graph
The victim-list element tells you which process SQL Server killed. Cross-reference the surviving process’s SQL text against the killed one’s — this is exactly how you confirm whether inconsistent access order (the classic cause) is really what happened.
Beyond Theory: A Deadlock Involving a Table Scan
Not every deadlock is the classic “two transactions, opposite order” case from Course 2. A single transaction doing a large table scan can deadlock against a small, targeted UPDATE if the scan acquires and holds shared locks across a wide range while the update needs an exclusive lock inside that range. The fix here isn’t reordering — it’s often reducing the scan’s lock footprint with a better index (tying directly back to Module 2 and 3).
Enjoyed this?
Subscribe to get every new lesson as soon as it’s published, and share it with a developer who’d find it useful.
Latches vs Locks in SQL Server: The Difference That Trips Up Even Experienced DBAs
Both sound like “something is blocking something.” They protect completely different things, and misdiagnosing one as the other sends you fixing the wrong problem.
Two Different Jobs
Why the Distinction Matters in Practice
A DBA seeing high PAGELATCH_EX waits and reaching for the usual lock-blocking playbook (shorten transactions, change isolation level) is solving the wrong problem — latch contention is about physical memory structure access, not logical transaction isolation. It needs a completely different fix.
-- Distinguish the two directly from current waits
SELECT wait_type, COUNT(*) AS waiting_now
FROM sys.dm_os_waiting_tasks
WHERE wait_type LIKE 'LCK%' OR wait_type LIKE '%LATCH%'
GROUP BY wait_type;
The Most Common Latch Contention Pattern
PAGELATCH_EX waits on the last page of a table with an ever-increasing key (like an IDENTITY column) under very high concurrent insert load is the single most common latch contention scenario — every session is racing to insert into the same physical page. This exact pattern sets up the tempdb contention lesson next, which is the same underlying phenomenon at a system-table level.
Enjoyed this?
Subscribe to get every new lesson as soon as it’s published, and share it with a developer who’d find it useful.
Diagnosing TempDB Contention in SQL Server: GAM, SGAM, PFS, and Multiple Data Files
The previous lesson’s latch pattern shows up at system scale in one very specific, very common place: tempdb’s allocation pages, under heavy use of temp tables and table variables (yes — straight back to Course 2’s temp objects material).
What’s Actually Being Contended
Every tempdb data file has special allocation-tracking pages: GAM (Global Allocation Map), SGAM (Shared GAM), and PFS (Page Free Space). Every session creating a temp table or table variable must touch these pages to claim space — under high concurrency, many sessions latch-wait on the same few physical pages.
Diagnosing It
-- High PAGELATCH waits specifically on tempdb pages is the signature
SELECT wait_type, wait_time_ms, waiting_tasks_count
FROM sys.dm_os_wait_stats
WHERE wait_type LIKE 'PAGELATCH%'
ORDER BY wait_time_ms DESC;
-- Confirm it's tempdb specifically
SELECT session_id, wait_type, resource_description
FROM sys.dm_os_waiting_tasks
WHERE resource_description LIKE '2:%'; -- database_id 2 = tempdb
The Standard Fix: Multiple Equally-Sized Data Files
-- Common starting guidance: one tempdb data file per CPU core, up to ~8, all EQUAL size
ALTER DATABASE tempdb ADD FILE (NAME = tempdev2, FILENAME = 'D:tempdbtempdev2.ndf', SIZE = 1024MB, FILEGROWTH = 256MB);
-- Repeat with matching sizes for tempdev3, tempdev4...
Equal size matters: SQL Server’s proportional-fill allocation favors the file with the most free space, so unequal files defeat the round-robin benefit entirely — a genuinely common mistake when adding files without matching existing sizes.
Enjoyed this?
Subscribe to get every new lesson as soon as it’s published, and share it with a developer who’d find it useful.
PIVOT, UNPIVOT, and CROSS APPLY in SQL Server: Solving Top-N-Per-Group
Two more tools that solve problems plain JOINs and GROUP BY genuinely can’t express cleanly — reshaping rows into columns, and joining a table to a per-row subquery in a way a normal JOIN’s ON clause structurally cannot do.
PIVOT: Rows Into Columns
SELECT region, [Dana], [Rahul], [Elena]
FROM (SELECT region, salesperson, amount FROM dbo.Sale) src
PIVOT (SUM(amount) FOR salesperson IN ([Dana], [Rahul], [Elena])) AS pvt;
The salesperson values become column headers — this is exactly the shape a spreadsheet-style report needs, and exactly the shape raw relational data never naturally has. The tradeoff: the column list [Dana], [Rahul], [Elena] must be known and hardcoded at query-write time — PIVOT can’t dynamically discover “whatever salespeople happen to exist.” A fully dynamic column list needs dynamic SQL (Chapter 1) to build the PIVOT statement’s IN list at runtime.
UNPIVOT: The Reverse
SELECT region, salesperson, amount
FROM (SELECT region, [Dana], [Rahul], [Elena] FROM dbo.vw_RegionTotals_Wide) src
UNPIVOT (amount FOR salesperson IN ([Dana], [Rahul], [Elena])) AS unpvt;
Turns spreadsheet-shaped, wide data back into normalized, tall rows — genuinely useful when importing an Excel-style export where each salesperson got their own column.
CROSS APPLY: The Top-N-Per-Group Solution
SELECT s.region, top_sale.amount, top_sale.sale_date
FROM (SELECT DISTINCT region FROM dbo.Sale) s
CROSS APPLY (
SELECT TOP 1 amount, sale_date FROM dbo.Sale WHERE region = s.region ORDER BY amount DESC
) AS top_sale;
Why APPLY, Not JOIN
CROSS APPLY behaves like an INNER JOIN, but the right side can reference the left row directly (as s.region is referenced inside the CROSS APPLY subquery above) — exactly what “top 1 sale per region” needs, and something a standard JOIN’s ON clause is syntactically incapable of expressing. OUTER APPLY is the LEFT JOIN equivalent, keeping left rows even when the applied subquery returns nothing (a region with zero sales still appears, with NULLs for the top-sale columns).
The Window-Function Alternative, Compared
-- The same "top sale per region" answer, using Chapter 6's window functions instead
WITH Ranked AS (
SELECT region, amount, sale_date,
ROW_NUMBER() OVER (PARTITION BY region ORDER BY amount DESC) AS rn
FROM dbo.Sale
)
SELECT region, amount, sale_date FROM Ranked WHERE rn = 1;
Both approaches give the same result here. As a rule of thumb: reach for CROSS APPLY when the “top N” logic needs to pull in columns or computations that don’t fit neatly into a single window function (a call to a table-valued function per row, for instance); reach for a window function with ROW_NUMBER() = 1 when the whole thing is expressible as ordinary columns from one table, since it’s typically the more efficient, more idiomatic choice for that simpler case.
Practice tip: Solve “top 2 sales per region” (not just top 1) both ways — CROSS APPLY with TOP 2, and the window-function version with WHERE rn <= 2. Confirm both return the same rows, then decide for yourself which reads more clearly to you; that judgment call is exactly what real T-SQL code review conversations are made of.
Enjoyed this?
Subscribe to get every new SQL Server lesson as soon as it’s published, and share it with a developer who’d find it useful.
Want the full structured course with quizzes, projects, and 10+ exercises per chapter? Check out SQL Server for Developers & DBAs, coming soon on this site.
SQL Server Query Store: How It Works and How to Force a Better Plan
The plan cache (Module 1) resets on restart and only shows current state. Query Store persists query and plan performance history per database, across restarts — and lets you directly force a known-good plan.
Enabling It
ALTER DATABASE YourDatabase SET QUERY_STORE = ON;
ALTER DATABASE YourDatabase SET QUERY_STORE (OPERATION_MODE = READ_WRITE);
Finding Regressed Queries
SELECT q.query_id, qt.query_sql_text, rs.avg_duration, rs.last_execution_time
FROM sys.query_store_query q
JOIN sys.query_store_query_text qt ON q.query_text_id = qt.query_text_id
JOIN sys.query_store_plan p ON q.query_id = p.query_id
JOIN sys.query_store_runtime_stats rs ON p.plan_id = rs.plan_id
ORDER BY rs.avg_duration DESC;
The Feature That Directly Fixes Parameter Sniffing: Forcing a Plan
This is a genuinely production-safe response to the Module 3 parameter sniffing problem — no code deployment needed, immediately reversible, and the exact plan is verifiable (unlike a hint that only influences future compilation).
Enjoyed this?
Subscribe to get every new lesson as soon as it’s published, and share it with a developer who’d find it useful.
Building Reusable Extended Events Sessions in SQL Server for Ongoing Monitoring
The one-off XEvents session from the 13-Hour Delete case study was diagnostic and temporary. A production monitoring session needs to run continuously with minimal overhead — different design goals entirely.
Designing for Low Overhead
CREATE EVENT SESSION LongRunningQueries ON SERVER
ADD EVENT sqlserver.sql_statement_completed (
ACTION (sqlserver.sql_text, sqlserver.username, sqlserver.client_hostname)
WHERE duration > 5000000 -- 5 seconds, in microseconds — filter aggressively
)
ADD TARGET package0.event_file (
SET filename = N'LongRunningQueries', max_file_size = 50, max_rollover_files = 5
)
WITH (MAX_MEMORY = 4096 KB, EVENT_RETENTION_MODE = ALLOW_SINGLE_EVENT_LOSS, MAX_DISPATCH_LATENCY = 5 SECONDS);
GO
ALTER EVENT SESSION LongRunningQueries ON SERVER STATE = START;
Three choices make this production-safe rather than a diagnostic one-off: an aggressive WHERE duration > filter (only capture what actually matters), a rollover file target with a size cap (bounded disk usage), and ALLOW_SINGLE_EVENT_LOSS (never let monitoring itself become a bottleneck).
Auto-Starting on Server Restart
ALTER EVENT SESSION LongRunningQueries ON SERVER WITH (STARTUP_STATE = ON);
Reading the Results Later
SELECT event_data.value('(event/@timestamp)[1]', 'DATETIME2') AS event_time,
event_data.value('(event/data[@name="duration"]/value)[1]', 'BIGINT') / 1000000.0 AS duration_sec,
event_data.value('(event/action[@name="sql_text"]/value)[1]', 'NVARCHAR(MAX)') AS sql_text
FROM sys.fn_xe_file_target_read_file('LongRunningQueries*.xel', NULL, NULL, NULL)
CROSS APPLY (SELECT CAST(event_data AS XML) AS event_data) ed
ORDER BY event_time DESC;
Enjoyed this?
Subscribe to get every new lesson as soon as it’s published, and share it with a developer who’d find it useful.
SQL Server PerfMon Counters That Actually Matter, Plus the Complete DMV Toolkit
Windows Performance Monitor exposes hundreds of SQL Server counters. Most are noise. Here are the handful worth an actual dashboard tile, plus every DMV this course has used, in one place.
The PerfMon Counters Worth Watching
Counter
What a bad value means
Page Life Expectancy
Low = buffer pool pressure, pages evicted quickly (Module 1)
Batch Requests/sec
Your baseline throughput metric — track trend, not absolute value
Compilations/sec vs Batch Requests/sec
High ratio = excessive recompiling, often ad-hoc query bloat (Module 1)
Lock Waits/sec
Rising trend = growing blocking problem (Module 5)
Query Store, Extended Events, DMVs, and PerfMon aren’t competing tools — they answer different question shapes. PerfMon: is there a trend problem over time? DMVs: what’s the current/historical state right now? Extended Events: capture specific events as they happen. Query Store: how has this specific query’s performance evolved? Combine them per the Evidence-First workflow rather than reaching for just one out of habit.
Enjoyed this?
Subscribe to get every new lesson as soon as it’s published, and share it with a developer who’d find it useful.
In-Memory OLTP in SQL Server: When Memory-Optimized Tables Actually Help
This is the direct architectural answer to Module 5’s latch contention problem — tables that avoid locks and latches almost entirely, at the cost of real constraints on how you use them.
Creating a Memory-Optimized Table
-- Requires a MEMORY_OPTIMIZED_DATA filegroup on the database first
CREATE TABLE dbo.SessionState (
session_id UNIQUEIDENTIFIER NOT NULL PRIMARY KEY NONCLUSTERED,
user_id INT NOT NULL,
last_activity DATETIME2 NOT NULL,
INDEX IX_UserId NONCLUSTERED (user_id)
) WITH (MEMORY_OPTIMIZED = ON, DURABILITY = SCHEMA_AND_DATA);
Why It Avoids the Contention From Module 5
Natively Compiled Procedures: The Other Half
CREATE PROCEDURE dbo.usp_UpdateSessionActivity
@session_id UNIQUEIDENTIFIER
WITH NATIVE_COMPILATION, SCHEMABINDING
AS
BEGIN ATOMIC WITH (TRANSACTION ISOLATION LEVEL = SNAPSHOT, LANGUAGE = 'us_english')
UPDATE dbo.SessionState SET last_activity = SYSDATETIME() WHERE session_id = @session_id;
END;
Natively compiled procedures are compiled to actual machine code, not interpreted T-SQL — the performance ceiling is dramatically higher, but the T-SQL surface area supported inside them is deliberately restricted (no dynamic SQL, limited function support).
When This Is (and Isn’t) the Right Tool
In-Memory OLTP genuinely shines for extreme-throughput, high-contention scenarios like session state, real-time bidding, or IoT ingestion — exactly the ever-increasing-key latch contention pattern from Module 5. It’s a poor fit for general-purpose reporting tables or anything needing the full T-SQL surface (complex constraints, most trigger types). Reach for it only after confirming, with evidence, that lock/latch contention is the actual bottleneck — not as a default upgrade.
Enjoyed this?
Subscribe to get every new lesson as soon as it’s published, and share it with a developer who’d find it useful.
Resource Governor in SQL Server: Isolating Workloads on a Shared Instance
A classic problem: an analyst runs an ad-hoc report against the same instance serving the production OLTP app, and it eats all available CPU. Resource Governor caps this at the engine level, without a second server.
The Three Pieces
-- 1. Resource pool: a slice of CPU/memory
CREATE RESOURCE POOL ReportingPool WITH (MAX_CPU_PERCENT = 30, MAX_MEMORY_PERCENT = 20);
GO
-- 2. Workload group: sits inside a pool, can set query-level limits too
CREATE WORKLOAD GROUP ReportingGroup
WITH (REQUEST_MAX_CPU_TIME_SEC = 60)
USING ReportingPool;
GO
-- 3. Classifier function: routes incoming connections to the right group
CREATE FUNCTION dbo.fn_ClassifyLogin() RETURNS SYSNAME
WITH SCHEMABINDING
AS
BEGIN
IF SUSER_SNAME() = 'reporting_svc'
RETURN 'ReportingGroup';
RETURN 'default';
END;
GO
ALTER RESOURCE GOVERNOR WITH (CLASSIFIER_FUNCTION = dbo.fn_ClassifyLogin);
ALTER RESOURCE GOVERNOR RECONFIGURE;
The Guarantee This Provides
This isn’t a substitute for actually fixing a bad query (Modules 2-4 still apply) — it’s a blast-radius guarantee. Even an un-tuned, missing-index report query can no longer starve the production OLTP workload sharing the instance.
Enjoyed this?
Subscribe to get every new lesson as soon as it’s published, and share it with a developer who’d find it useful.