Skip to content

Bits of .NET

Daily micro-tips for C#, SQL, performance, and scalable backend engineering.

  • Asp.Net Core
  • C#
  • SQL
  • JavaScript
  • CSS
  • About
  • ErcanOPAK.com
  • No Access
  • Privacy Policy
SQL

SQL Server: Fix a Query Slowdown That Only Appears After the Table Crosses a Row-Count Threshold

- 27.09.26 - ErcanOPAK

🗄️ The Query That Ran Fine for Months and Then Suddenly Didn’t

A query that performs well for a long time and then degrades sharply, with no code change involved at all, is very often explained by the query optimizer switching strategies once a table’s row count crosses an internal threshold – a plan that reasonably chose a nested loop join for a small table can become genuinely wrong once that table grows large enough that a hash or merge join would perform far better, and SQL Server doesn’t necessarily pick a new plan on its own until statistics are updated or the plan is evicted from cache for an unrelated reason.

🔎 The Problem

-- A query joining Orders to a lookup-style table that started small
-- and grew steadily over many months:
SELECT o.OrderId, o.Total, c.CustomerName
FROM Orders o
JOIN Customers c ON o.CustomerId = c.CustomerId
WHERE o.CreatedAt >= '"'"'2026-01-01'"'"';

-- Early on, Customers had a few thousand rows - a nested loop join
-- (looking up each order'"'"'s customer one at a time) was genuinely the
-- fastest plan available, and the optimizer correctly chose it.
--
-- A year later, Customers has grown to several million rows. The exact
-- same query, still using a cached nested-loop plan from when the
-- table was small, is now doing millions of individual index lookups
-- instead of a single efficient hash join - and nothing about the SQL
-- itself changed at all to cause it.

✅ Fix: Keep Statistics Current, and Watch for Plans That Stopped Fitting

  • Confirming auto-update statistics is enabled (and, for a large, fast-growing table, considering a more frequent manual UPDATE STATISTICS on a schedule) ensures the optimizer has an accurate row-count estimate to base its join strategy on, rather than working from stale statistics that still describe the table as it was much earlier.
  • Clearing the specific cached plan (or the whole plan cache, in a maintenance window) forces a fresh compilation the next time the query runs – if the newly compiled plan performs meaningfully better with current statistics, that confirms a stale, no-longer-appropriate cached plan was the actual cause rather than the SQL or the schema itself.
  • Query Store, if enabled, keeps a history of plans actually used for a given query over time – comparing the plan from before the slowdown against the one in use now (specifically looking at whether the join type changed) is the most direct way to confirm this exact mechanism rather than guessing at the cause from execution time alone.

⚠️ Why This Looks Like a Mystery When It First Appears

  • Nothing about the query, the schema, or the application code needs to change at all for this to happen – the table simply grew past a threshold where a previously-correct plan choice quietly stopped being correct, which makes ‘what changed?’ a genuinely hard question to answer just by looking at recent deployments or commits.
  • A plan sitting in cache can persist for a surprisingly long time without being recompiled, especially on a server that isn’t restarted often and doesn’t experience the kind of memory pressure that would otherwise evict old plans – so the actual moment the table crossed the threshold and the moment the slowdown becomes noticeable can be separated by weeks or months.

A query plan chosen when a table was small doesn’t come with an expiration date – it just quietly stops being the right answer once the table it was built for isn’t small anymore.

— Database Engineer

Related posts:

The Secret Weapon: APPLY — Why CROSS APPLY Beats Subqueries

SQL Server: Why Rebuilding a Clustered Index Doesn't Fix Fragmented Non-Clustered Indexes

SQL: Why Your Query Plan is Wrong - The Importance of Statistics

Post Views: 4

Post navigation

Docker: Fix a Health Check That Passes Locally but Always Fails Inside Compose
Windows 11: Fix a Keyboard Layout That Randomly Switches Mid-Typing

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

October 2026
M T W T F S S
 1234
567891011
12131415161718
19202122232425
262728293031  
« Sep    

Most Viewed Posts

  • Get the User Name and Domain Name from an Email Address in SQL (973)
  • How to make theater mode the default for Youtube (950)
  • How to add default value for Entity Framework migrations for DateTime and Bool (939)
  • Get the First and Last Word from a String or Sentence in SQL (847)
  • How to select distinct rows in a datatable in C# (836)
  • How to enable, disable and check if Service Broker is enabled on a database in SQL Server (624)
  • Add Constraint to SQL Table to ensure email contains @ (590)
  • Average of all values in a column that are not zero in SQL (553)
  • How to use Map Mode for Vertical Scroll Mode in Visual Studio (526)
  • Find numbers with more than two decimal places in SQL (468)

Recent Posts

  • CSS: Fix a prefers-color-scheme Media Query That Gets Silently Overridden by a Browser Extension’s Forced Dark Mode
  • Git: Fix a Merge Commit That Silently Drops a File Because Both Branches Deleted It Differently
  • HTML5: Fix a Native Lazy-Loading Image That Never Loads Because It Sits Inside a Hidden Tab Until the User Clicks It
  • The AI Prompt That Traces a Null Reference Exception Back to the Exact Line That First Produced the Null
  • The AI Prompt That Turns a Gym Membership Contract’s Fine Print Into a Plain-English List of Cancellation Steps
  • Photoshop: Fix a Color Profile Mismatch That Makes Printed Output Look Nothing Like What You Saw On Screen
  • WordPress: Fix Search Results That Return Pages From a Theme You Deactivated Months Ago
  • Visual Studio: Fix a Test Project That Builds Fine Alone but Fails to Discover Any Tests After a NuGet Restore
  • ASP.NET Core: Fix a File Upload That Times Out on Slow Connections Only Because Kestrel’s Minimum Data Rate Feature Kicked In
  • JavaScript: Fix an Array Destructuring Default Value That Silently Never Applies Because null Was Passed Instead of Undefined

Most Viewed Posts

  • Get the User Name and Domain Name from an Email Address in SQL (973)
  • How to make theater mode the default for Youtube (950)
  • How to add default value for Entity Framework migrations for DateTime and Bool (939)
  • Get the First and Last Word from a String or Sentence in SQL (847)
  • How to select distinct rows in a datatable in C# (836)

Recent Posts

  • CSS: Fix a prefers-color-scheme Media Query That Gets Silently Overridden by a Browser Extension’s Forced Dark Mode
  • Git: Fix a Merge Commit That Silently Drops a File Because Both Branches Deleted It Differently
  • HTML5: Fix a Native Lazy-Loading Image That Never Loads Because It Sits Inside a Hidden Tab Until the User Clicks It
  • The AI Prompt That Traces a Null Reference Exception Back to the Exact Line That First Produced the Null
  • The AI Prompt That Turns a Gym Membership Contract’s Fine Print Into a Plain-English List of Cancellation Steps

Social

  • ErcanOPAK.com
  • GoodReads
  • LetterBoxD
  • Linkedin
  • The Blog
  • Twitter
© 2026 Bits of .NET | Built with Xblog Plus free WordPress theme by wpthemespace.com