A plan-cache hit ratio of 99% looks reassuring. Before calling that good news, though, I want to know what the server is doing with those reused plans.
Improving plan-cache efficiency is about making better use of SQL Server’s CPU and memory: reducing unnecessary compilation work while avoiding a cache filled with plans that are rarely reused. Reusing suitable plans saves compilation resources, and reducing the memory footprint of single-use plans can help relieve memory pressure.
But reuse alone is not the whole story. A reused plan is not necessarily an efficient plan for every execution, so the goal is better workload performance, not just a better-looking percentage.
Let’s look at what the plan-cache hit ratio actually measures, how to collect useful numbers, and which changes are worth considering when the evidence points to a problem. Along the way, we’ll connect plan reuse, compilation overhead, and cache memory usage to the outcome that matters: how well the workload performs.
What the plan-cache hit ratio actually tells us
Cache-hit ratio is the ratio between cache hits and cache lookups for the selected cache instance. When SQL Server looked in this cache, how often did it find a match?
That is not the same as saying, “This percentage of application queries ran without compilation,” especially when looking at performance counter _Total, which includes more than SQL execution plans. These are the three instances:
- SQL Plans: Ad hoc, auto-parameterized, and prepared SQL plans.
- Object Plans: Plans associated with stored procedures, functions, and triggers.
- _Total: All cache-instance types, including categories like bound trees and temporary-table-related cache information.
Start with a quick measurement
Start with this accumulated-counter view. It is a useful first look, but it is not a measurement of just the last few seconds.
;WITH CacheCounters AS ( SELECT RTRIM(object_name) AS object_name, RTRIM(instance_name) AS cache_instance, MAX(CASE WHEN counter_name = N'Cache hit ratio' THEN cntr_value END) AS hits, MAX(CASE WHEN counter_name = N'Cache hit ratio base' THEN cntr_value END) AS lookups FROM sys.dm_os_performance_counters WHERE RTRIM(object_name) LIKE N'%:Plan Cache' AND instance_name IN (N'_Total', N'SQL Plans', N'Object Plans') AND counter_name IN (N'Cache hit ratio', N'Cache hit ratio base') GROUP BY object_name, instance_name ) SELECT object_name, cache_instance, hits, lookups, CAST(100.0 * hits / NULLIF(lookups, 0) AS decimal(9, 2)) AS accumulated_hit_ratio_pct FROM CacheCounters ORDER BY object_name, cache_instance; GO
The 100.0 keeps the calculation from using integer-only division, and NULLIF protects against division by zero. If the denominator is zero, the result is NULL, not a meaningful 0% hit ratio.
For troubleshooting, I would not stop here. An accumulated ratio can hide a change that began during the current incident, so the next question is: “What happened during the interval I actually care about?”
Measure recent activity, not just accumulated totals
The raw values for /sec counters in sys.dm_os_performance_counters are accumulated counts; to calculate a rate, take two samples and divide the difference by elapsed seconds. For a recent cache-hit ratio, use the change in hits divided by the change in lookups over that same interval, rather than subtracting two accumulated percentages.
This sampler waits approximately 15 seconds and returns two result sets: recent cache-hit ratios, followed by batch and compilation rates. It only reads system DMVs and stores its samples in local table variables; it does not clear the cache or change configuration.
DECLARE @Samples TABLE ( sample_no tinyint, object_name nvarchar(128), counter_name nvarchar(128), instance_name nvarchar(128), counter_value bigint ); DECLARE @SampleNo tinyint = 1; DECLARE @StartTime datetime2(3), @EndTime datetime2(3); WHILE @SampleNo <= 2 BEGIN IF @SampleNo = 1 SET @StartTime = SYSUTCDATETIME(); ELSE SET @EndTime = SYSUTCDATETIME(); INSERT INTO @Samples SELECT @SampleNo, RTRIM(object_name), RTRIM(counter_name), RTRIM(instance_name), cntr_value FROM sys.dm_os_performance_counters WHERE ( RTRIM(object_name) LIKE N'%:Plan Cache' AND instance_name IN (N'_Total', N'SQL Plans', N'Object Plans') AND counter_name IN (N'Cache hit ratio', N'Cache hit ratio base') ) OR ( RTRIM(object_name) LIKE N'%:SQL Statistics' AND counter_name IN (N'Batch Requests/sec', N'SQL Compilations/sec', N'SQL Re-Compilations/sec') ); IF @SampleNo = 1 WAITFOR DELAY '00:00:15'; SET @SampleNo += 1; END; DECLARE @Seconds decimal(19, 3) = DATEDIFF(millisecond, @StartTime, @EndTime) / 1000.0; DECLARE @Deltas TABLE ( object_name nvarchar(128), counter_name nvarchar(128), instance_name nvarchar(128), delta_value bigint ); INSERT INTO @Deltas SELECT s2.object_name, s2.counter_name, s2.instance_name, s2.counter_value - s1.counter_value FROM @Samples AS s1 JOIN @Samples AS s2 ON s2.object_name = s1.object_name AND s2.counter_name = s1.counter_name AND s2.instance_name = s1.instance_name AND s2.sample_no = 2 WHERE s1.sample_no = 1; -- Result 1: recent cache-hit ratios. ;WITH CacheDeltas AS ( SELECT object_name, instance_name, MAX(CASE WHEN counter_name = N'Cache hit ratio' THEN delta_value END) AS hits, MAX(CASE WHEN counter_name = N'Cache hit ratio base' THEN delta_value END) AS lookups FROM @Deltas WHERE object_name LIKE N'%:Plan Cache' GROUP BY object_name, instance_name ) SELECT @Seconds AS elapsed_seconds, object_name, instance_name AS cache_instance, hits AS interval_hits, lookups AS interval_lookups, CASE WHEN hits >= 0 AND lookups > 0 AND hits <= lookups THEN CAST(100.0 * hits / NULLIF(lookups, 0) AS decimal(9, 2)) END AS interval_hit_ratio_pct, CASE WHEN hits IS NULL OR lookups IS NULL THEN N'Missing counter; investigate' WHEN hits < 0 OR lookups < 0 OR hits > lookups THEN N'Reset or inconsistent sample; resample' WHEN lookups = 0 THEN N'No lookups; ratio is undefined' ELSE N'Compare with workload baseline' END AS interpretation FROM CacheDeltas ORDER BY object_name, instance_name; -- Result 2: recent batch and compilation rates. SELECT @Seconds AS elapsed_seconds, counter_name, delta_value AS interval_events, CASE WHEN delta_value >= 0 THEN CAST(delta_value / NULLIF(@Seconds, 0) AS decimal(19, 2)) END AS events_per_second FROM @Deltas WHERE object_name LIKE N'%:SQL Statistics' ORDER BY counter_name; GO
Treat 15 seconds as a quick diagnostic sample, not a workload baseline. Repeat the collection during representative periods; if counters are missing or the sample is inconsistent, investigate or resample rather than interpreting missing data as zero activity.
Here is how to read the second result set:
- Batch Requests/sec: The number of incoming Transact-SQL batches per second, not the number of individual statements.
- SQL Compilations/sec: How often SQL Server enters the compilation code path, including compilations caused by statement-level recompilation.
- SQL Re-Compilations/sec: The number of statement recompilations triggered per second.
Do not add compilations and recompilations together: the compilation counter already includes statement-level recompilations. I would compare these rates with the workload’s normal batch activity, CPU usage, and response times before deciding that compilation is the problem.
Compilation, recompilation, and eviction are different things
It is tempting to call everything a “recompile” whenever SQL Server has to build a plan. Keeping the terms separate makes troubleshooting much easier.
- Compilation: SQL Server builds a plan when it cannot reuse a suitable existing one.
- Recompilation: SQL Server regenerates a statement’s plan, for example after a relevant statistics or schema change, or because recompilation was explicitly requested.
- Eviction or clearing: A plan is removed from cache; a later execution may then need compilation.
- A different cache entry: A variation in query text or relevant execution context can require a separate cached plan, rather than recompiling the original one.
Check how much memory the cached entries use
;WITH CacheGroups AS ( SELECT objtype, cacheobjtype, COUNT_BIG(*) AS entry_count, SUM(CONVERT(bigint, size_in_bytes)) AS total_bytes, SUM(CASE WHEN usecounts = 1 THEN CONVERT(bigint, size_in_bytes) ELSE CONVERT(bigint, 0) END) AS single_lookup_bytes FROM sys.dm_exec_cached_plans GROUP BY objtype, cacheobjtype ) SELECT objtype, cacheobjtype, entry_count, CAST(total_bytes / 1048576.0 AS decimal(19, 2)) AS size_mb, CAST(100.0 * total_bytes / NULLIF(SUM(total_bytes) OVER (), 0) AS decimal(9, 2)) AS pct_of_reported_cache_bytes, CAST(single_lookup_bytes / 1048576.0 AS decimal(19, 2)) AS single_lookup_mb FROM CacheGroups ORDER BY total_bytes DESC, objtype, cacheobjtype; GO
Choose the fix that matches the evidence
Make parameterization consistent
EXEC sys.sp_executesql N'SELECT OrderID, OrderDate FROM dbo.Orders WHERE CustomerID = @CustomerID;', N'@CustomerID int', @CustomerID = 101; GO
Enable optimize for ad hoc workloads when single-use plans consume substantial memory
- Second use: SQL Server compiles the batch again and replaces the stub with a full compiled plan.
- Existing entries: Enabling the option affects new plans, not full plans already sitting in cache.
- Troubleshooting: A stub does not contain a graphical or XML execution plan to inspect.
Be selective with forced parameterization
Keep useful maintenance; remove unnecessary work
Before adding memory or clearing the cache
Check whether query execution actually improved
SELECT TOP (10) SUBSTRING ( st.text, (qs.statement_start_offset / 2) + 1, ( (CASE WHEN qs.statement_end_offset = -1 THEN DATALENGTH(st.text) ELSE qs.statement_end_offset END - qs.statement_start_offset) / 2 ) + 1 ) AS statement_text, qs.creation_time AS plan_creation_time, qs.last_execution_time, qs.execution_count, CAST(qs.total_elapsed_time / (1000.0 * NULLIF(qs.execution_count, 0)) AS decimal(19, 2)) AS avg_elapsed_ms, CAST(qs.total_worker_time / (1000.0 * NULLIF(qs.execution_count, 0)) AS decimal(19, 2)) AS avg_cpu_ms, qs.total_logical_reads, qs.plan_handle FROM sys.dm_exec_query_stats AS qs CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st WHERE qs.execution_count >= 5 ORDER BY avg_elapsed_ms DESC; GO
A useful checklist
- What is the symptom: high CPU, slow queries, compilation activity, or memory pressure?
- Is the hit-ratio change unusual for this workload and cache instance?
- What do recent compilation and recompilation rates show?
- Which cached entries consume the memory?
- Does the evidence point to parameterization, recompilation causes, memory pressure, or poor execution plans?
- What is the smallest change we can test safely?
- Did CPU usage, response time, or memory use improve afterward?
Useful Links and Further Reading
Want to dig a little deeper? These Microsoft references cover the counters, DMVs, and tuning options discussed in this article.
- Query Processing Architecture Guide : How SQL Server compiles, caches, reuses, and recompiles execution plans.
- Plan Cache Performance Counters : Definitions of cache-hit ratio and the different cache instances, including SQL Plans, Object Plans, and _Total.
- sys.dm_os_performance_counters : How to interpret raw counter values, calculate interval rates, and check permission requirements.
- SQL Statistics Performance Counters : What Batch Requests/sec, SQL Compilations/sec, and SQL Re-Compilations/sec actually measure.
- sys.dm_exec_cached_plans : Details about cached-plan types, memory usage, and the important limitations of interpreting usecounts.
- sys.dm_exec_query_stats : Execution statistics for currently cached statements, including CPU time, elapsed time, logical reads, and execution counts.
- Optimize for Ad Hoc Workloads : When compiled plan stubs can reduce cache memory usage, and the trade-offs to understand before enabling this option.
- Control Parameterization with Plan Guides : How TEMPLATE plan guides can change parameterization behavior for eligible classes of queries.
- Index Maintenance Guidance : Differences between reorganizing and rebuilding indexes, their effects on statistics, and how to evaluate maintenance benefits.
- Monitor Performance with Query Store : Use query, plan, and runtime-statistics history to investigate performance changes beyond the current plan-cache contents.
- Parameter Sensitive Plan Optimization : Why SQL Server may intentionally keep multiple plan variants for a parameterized query.