{"id":52,"date":"2024-03-14T17:38:40","date_gmt":"2024-03-14T17:38:40","guid":{"rendered":"https:\/\/twobitranch.io\/?p=52"},"modified":"2026-09-11T12:42:58","modified_gmt":"2026-09-11T12:42:58","slug":"sql-stored-procedure-performance-misery","status":"publish","type":"post","link":"https:\/\/twobitranch.io\/index.php\/2024\/03\/14\/sql-stored-procedure-performance-misery\/","title":{"rendered":"SQL Stored procedure performance misery"},"content":{"rendered":"\n<h3 class=\"wp-block-heading\">There is a special kind of sadness that comes from being told:<\/h3>\n\n\n\n<blockquote class=\"wp-block-quote\">\n<p>&#8220;The stored procedure is slow.&#8221;<\/p>\n<\/blockquote>\n\n\n\n<p>At first, you remain optimistic.<\/p>\n\n\n\n<p>You open SQL Server Management Studio. You run the procedure. It takes 14 seconds.<\/p>\n\n\n\n<p>Okay. That&#8217;s not great, but 14 seconds isn&#8217;t the end of civilization.<\/p>\n\n\n\n<p>Then someone tells you:<\/p>\n\n\n\n<blockquote class=\"wp-block-quote\">\n<p>&#8220;It normally takes about two minutes.&#8221;<\/p>\n<\/blockquote>\n\n\n\n<p>You run it again.<\/p>\n\n\n\n<p>This time it takes 47 seconds.<\/p>\n\n\n\n<p>You run it a third time.<\/p>\n\n\n\n<p>Three seconds.<\/p>\n\n\n\n<p>You stare at the screen.<\/p>\n\n\n\n<p>The database has decided to gaslight you.<\/p>\n\n\n\n<p>Welcome to the wonderful world of <strong>SQL stored procedure performance misery<\/strong>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">&#8220;But It Works Fine on My Machine&#8221;<\/h3>\n\n\n\n<p>This phrase has a SQL Server equivalent:<\/p>\n\n\n\n<blockquote class=\"wp-block-quote\">\n<p>&#8220;It runs in SSMS.&#8221;<\/p>\n<\/blockquote>\n\n\n\n<p>Of course it does.<\/p>\n\n\n\n<p>You&#8217;re testing it manually with a carefully selected set of parameters, no application traffic, no concurrent users, no blocking, and probably immediately after SQL Server decided to give you the execution plan you wanted.<\/p>\n\n\n\n<p>Meanwhile, production is doing this:<code>Application \u2193 API \u2193 Connection Pool \u2193 SQL Server \u2193 Stored Procedure \u2193 View \u2193 Another View \u2193 Another Stored Procedure \u2193 Temporary Table \u2193 Cursor \u2193 Regret<\/code><\/p>\n\n\n\n<p>And somebody has opened 300 concurrent requests.<\/p>\n\n\n\n<p>Suddenly your three-second procedure has become a 90-second procedure.<\/p>\n\n\n\n<p>The database isn&#8217;t broken.<\/p>\n\n\n\n<p>It is simply having a day.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">The Stored Procedure That &#8220;Just Needs One More JOIN&#8221;<\/h3>\n\n\n\n<p>Every performance disaster begins innocently.<\/p>\n\n\n\n<p>Someone needs a new column.<\/p>\n\n\n\n<p>The developer opens the stored procedure:<code>SELECT CustomerId, OrderDate, Total FROM Orders;<\/code><\/p>\n\n\n\n<p>Easy.<\/p>\n\n\n\n<p>Add the customer name:<code>SELECT o.CustomerId, c.Name, o.OrderDate, o.Total FROM Orders o JOIN Customers c ON c.CustomerId = o.CustomerId;<\/code><\/p>\n\n\n\n<p>Done.<\/p>\n\n\n\n<p>Then someone needs the customer&#8217;s region.<\/p>\n\n\n\n<p>Another JOIN.<\/p>\n\n\n\n<p>Then their account manager.<\/p>\n\n\n\n<p>Another JOIN.<\/p>\n\n\n\n<p>Then the account manager&#8217;s department.<\/p>\n\n\n\n<p>Another JOIN.<\/p>\n\n\n\n<p>Then whether the customer has an active subscription.<\/p>\n\n\n\n<p>Another JOIN.<\/p>\n\n\n\n<p>Eventually:<code>FROM Orders o JOIN Customers c JOIN Addresses a JOIN Regions r JOIN AccountManagers am JOIN Departments d JOIN Subscriptions s JOIN SubscriptionPlans sp JOIN PaymentMethods pm JOIN ...<\/code><\/p>\n\n\n\n<p>At this point, the query isn&#8217;t fetching orders.<\/p>\n\n\n\n<p>It&#8217;s reconstructing civilization.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">The Execution Plan Has Become Modern Art<\/h3>\n\n\n\n<p>Eventually someone says:<\/p>\n\n\n\n<blockquote class=\"wp-block-quote\">\n<p>&#8220;Have you looked at the execution plan?&#8221;<\/p>\n<\/blockquote>\n\n\n\n<p>Yes.<\/p>\n\n\n\n<p>I have.<\/p>\n\n\n\n<p>The execution plan contains a large green box connected to 37 other boxes.<\/p>\n\n\n\n<p>There are arrows everywhere.<\/p>\n\n\n\n<p>Some are thick.<\/p>\n\n\n\n<p>Some are thin.<\/p>\n\n\n\n<p>One of them seems to disappear behind another operator.<\/p>\n\n\n\n<p>There is a warning triangle.<\/p>\n\n\n\n<p>You zoom in.<\/p>\n\n\n\n<p>You zoom out.<\/p>\n\n\n\n<p>You zoom in again.<\/p>\n\n\n\n<p>You realize you&#8217;ve been staring at it for eight minutes and still don&#8217;t know what it means.<\/p>\n\n\n\n<p>Then you notice:<\/p>\n\n\n\n<p><strong>Table Scan.<\/strong><\/p>\n\n\n\n<p>On a table containing 86 million rows.<\/p>\n\n\n\n<p>Ah.<\/p>\n\n\n\n<p>There it is.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">The Missing Index Recommendation<\/h2>\n\n\n\n<p>SQL Server helpfully suggests:<\/p>\n\n\n\n<blockquote class=\"wp-block-quote\">\n<p>Missing Index: Create this index.<\/p>\n<\/blockquote>\n\n\n\n<p>Excellent.<\/p>\n\n\n\n<p>We create it.<\/p>\n\n\n\n<p>Performance improves dramatically.<\/p>\n\n\n\n<p>Everyone celebrates.<\/p>\n\n\n\n<p>Two months later:<code>INSERT INTO Orders ...<\/code><\/p>\n\n\n\n<p>is slow.<\/p>\n\n\n\n<p>Updates are slow.<\/p>\n\n\n\n<p>Deletes are slow.<\/p>\n\n\n\n<p>Storage has increased.<\/p>\n\n\n\n<p>Index maintenance now takes 42 minutes.<\/p>\n\n\n\n<p>SQL Server has 19 indexes on the table.<\/p>\n\n\n\n<p>The original query is still slow.<\/p>\n\n\n\n<p>Congratulations.<\/p>\n\n\n\n<p>You have solved one performance problem by creating four new ones.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">&#8220;Let&#8217;s Add NOLOCK&#8221;<\/h3>\n\n\n\n<p>This is where things get dangerous.<\/p>\n\n\n\n<p>Someone sees blocking and says:<\/p>\n\n\n\n<blockquote class=\"wp-block-quote\">\n<p>&#8220;Just add NOLOCK.&#8221;<\/p>\n<\/blockquote>\n\n\n\n<p>Suddenly:<code>FROM Orders WITH (NOLOCK)<\/code><\/p>\n\n\n\n<p>is everywhere.<\/p>\n\n\n\n<p>Why?<\/p>\n\n\n\n<p>Because it makes the query faster.<\/p>\n\n\n\n<p>Sometimes.<\/p>\n\n\n\n<p>It can also allow dirty reads and other concurrency anomalies.<\/p>\n\n\n\n<p>But those are details.<\/p>\n\n\n\n<p>The important thing is that the report now runs in 1.2 seconds and occasionally tells the finance department that an order exists when it doesn&#8217;t.<\/p>\n\n\n\n<p>Performance!<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">The Parameter Sniffing Horror Story<\/h3>\n\n\n\n<p>This is one of SQL Server&#8217;s favorite practical jokes.<\/p>\n\n\n\n<p>You have:<code>CREATE PROCEDURE GetOrders @CustomerId INT AS BEGIN SELECT * FROM Orders WHERE CustomerId = @CustomerId; END<\/code><\/p>\n\n\n\n<p>You test it:<code>EXEC GetOrders @CustomerId = 123;<\/code><\/p>\n\n\n\n<p>It&#8217;s fast.<\/p>\n\n\n\n<p>Production calls:<code>EXEC GetOrders @CustomerId = 999999;<\/code><\/p>\n\n\n\n<p>It&#8217;s slow.<\/p>\n\n\n\n<p>Someone calls it with another customer.<\/p>\n\n\n\n<p>Fast.<\/p>\n\n\n\n<p>Another customer.<\/p>\n\n\n\n<p>Slow.<\/p>\n\n\n\n<p>Another.<\/p>\n\n\n\n<p>Fast.<\/p>\n\n\n\n<p>You begin to suspect the database has developed preferences.<\/p>\n\n\n\n<p>Then someone says:<\/p>\n\n\n\n<blockquote class=\"wp-block-quote\">\n<p>&#8220;It&#8217;s probably parameter sniffing.&#8221;<\/p>\n<\/blockquote>\n\n\n\n<p>And now you are 14 browser tabs deep into articles explaining query plans, cardinality estimates, statistics, recompilation, and why SQL Server remembers things you didn&#8217;t ask it to remember.<\/p>\n\n\n\n<p>The database has essentially said:<\/p>\n\n\n\n<blockquote class=\"wp-block-quote\">\n<p>&#8220;You gave me a parameter once. I made some decisions. Good luck.&#8221;<\/p>\n<\/blockquote>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">SELECT *<\/h3>\n\n\n\n<p>There is one SQL statement that has caused more long-term suffering than it deserves:<code>SELECT *<\/code><\/p>\n\n\n\n<p>It starts innocently.<\/p>\n\n\n\n<blockquote class=\"wp-block-quote\">\n<p>&#8220;We&#8217;re just returning everything.&#8221;<\/p>\n<\/blockquote>\n\n\n\n<p>Then the table gets a new column.<\/p>\n\n\n\n<p>Then another.<\/p>\n\n\n\n<p>Then another.<\/p>\n\n\n\n<p>Now the query is returning 73 columns when the application needs six.<\/p>\n\n\n\n<p>But nobody wants to touch the stored procedure because:<\/p>\n\n\n\n<blockquote class=\"wp-block-quote\">\n<p>&#8220;It works.&#8221;<\/p>\n<\/blockquote>\n\n\n\n<p>Of course it works.<\/p>\n\n\n\n<p>So does carrying your entire house on your back.<\/p>\n\n\n\n<p>That doesn&#8217;t make it a good design.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">The 17-Year-Old Stored Procedure<\/h3>\n\n\n\n<p>You open a stored procedure and see:<code>-- Added by Bob - 2008 -- Added by Mike - 2011 -- Temporary fix - 2014 -- Performance fix - 2016 -- DO NOT REMOVE - 2017 -- Added for new reporting requirements - 2019 -- Hotfix - 2021 -- Temporary workaround - 2022 -- Added for customer request - 2024<\/code><\/p>\n\n\n\n<p>You scroll.<\/p>\n\n\n\n<p>The procedure is 3,800 lines long.<\/p>\n\n\n\n<p>There are 14 temporary tables.<\/p>\n\n\n\n<p>Six nested <code>IF<\/code> statements.<\/p>\n\n\n\n<p>A cursor.<\/p>\n\n\n\n<p>Dynamic SQL.<\/p>\n\n\n\n<p>Three calls to other stored procedures.<\/p>\n\n\n\n<p>One undocumented table.<\/p>\n\n\n\n<p>And a comment saying:<code>-- This seems to fix the issue. Don't ask me why.<\/code><\/p>\n\n\n\n<p>You close SSMS.<\/p>\n\n\n\n<p>You go make coffee.<\/p>\n\n\n\n<p>You consider becoming a farmer.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Cursors: Because Sometimes We Missed the 1980s<\/h3>\n\n\n\n<p>There are legitimate uses for cursors.<\/p>\n\n\n\n<p>But sometimes you encounter:<code>DECLARE customer_cursor CURSOR FOR SELECT CustomerId FROM Customers;<\/code><\/p>\n\n\n\n<p>followed by:<code>FETCH NEXT FROM customer_cursor<\/code><\/p>\n\n\n\n<p>inside a loop.<\/p>\n\n\n\n<p>Then inside the loop:<code>EXEC SomeStoredProcedure @CustomerId;<\/code><\/p>\n\n\n\n<p>And suddenly a query that could have been one set-based operation is executing 80,000 individual operations.<\/p>\n\n\n\n<p>Someone asks:<\/p>\n\n\n\n<blockquote class=\"wp-block-quote\">\n<p>&#8220;Why does the report take 11 minutes?&#8221;<\/p>\n<\/blockquote>\n\n\n\n<p>Because we have asked SQL Server to perform 80,000 tiny tasks while wearing a blindfold.<\/p>\n\n\n\n<p>SQL Server is extremely good at working with sets.<\/p>\n\n\n\n<p>It is considerably less excited about doing the same thing one row at a time.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">The Temp Table Empire<\/h3>\n\n\n\n<p>Temporary tables can be useful.<\/p>\n\n\n\n<p>Very useful.<\/p>\n\n\n\n<p>But sometimes a stored procedure creates:<code>#Customers #Orders #FilteredOrders #ActiveCustomers #CustomerTotals #CustomerRegions #FinalResults<\/code><\/p>\n\n\n\n<p>Then each table is populated from the previous table.<\/p>\n\n\n\n<p>Eventually the procedure resembles an ETL pipeline that nobody intentionally designed.<\/p>\n\n\n\n<p>The query isn&#8217;t slow because SQL Server can&#8217;t execute it.<\/p>\n\n\n\n<p>It&#8217;s slow because we&#8217;ve built a small data-processing framework inside a stored procedure.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Dynamic SQL: The Final Boss<\/h3>\n\n\n\n<p>Then you encounter:<code>SET @sql = ' SELECT ... FROM ... WHERE ' + @whereClause;<\/code><\/p>\n\n\n\n<p>You know immediately that you&#8217;re going to have a long afternoon.<\/p>\n\n\n\n<p>Dynamic SQL isn&#8217;t inherently bad.<\/p>\n\n\n\n<p>Sometimes it is exactly the right tool.<\/p>\n\n\n\n<p>But when a stored procedure dynamically constructs half the query, debugging becomes an adventure.<\/p>\n\n\n\n<p>You end up doing:<code>PRINT @sql;<\/code><\/p>\n\n\n\n<p>Then copying the output into another query window.<\/p>\n\n\n\n<p>Then fixing the missing quote.<\/p>\n\n\n\n<p>Then realizing the parameter isn&#8217;t there.<\/p>\n\n\n\n<p>Then adding another <code>PRINT<\/code>.<\/p>\n\n\n\n<p>Eventually you have reconstructed the query manually.<\/p>\n\n\n\n<p>At this point you are no longer debugging SQL.<\/p>\n\n\n\n<p>You&#8217;re reverse-engineering a compiler.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">The .NET Developer&#8217;s Contribution<\/h3>\n\n\n\n<p>ASP.NET Core developers aren&#8217;t innocent here.<\/p>\n\n\n\n<p>We can absolutely make SQL performance worse from the application layer.<\/p>\n\n\n\n<p>For example:<code>foreach (var customer in customers) { await repository.GetOrdersAsync(customer.Id); }<\/code><\/p>\n\n\n\n<p>Congratulations.<\/p>\n\n\n\n<p>You have invented the <strong>N+1 query problem<\/strong>.<\/p>\n\n\n\n<p>If there are 10,000 customers, congratulations again.<\/p>\n\n\n\n<p>You now have approximately 10,001 database calls.<\/p>\n\n\n\n<p>The stored procedure may be perfectly optimized.<\/p>\n\n\n\n<p>The application has simply decided to call it 10,000 times.<\/p>\n\n\n\n<p>Sometimes the SQL isn&#8217;t the problem.<\/p>\n\n\n\n<p>Sometimes the problem is the person calling SQL.<\/p>\n\n\n\n<p>And sometimes that person is us.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">The Repository That Hides Everything<\/h3>\n\n\n\n<p>Another classic architecture looks like:<code>Controller \u2193 Service \u2193 Repository \u2193 Stored Procedure \u2193 View \u2193 Another View \u2193 Table<\/code><\/p>\n\n\n\n<p>The developer sees:<code>await _repository.GetOrdersAsync();<\/code><\/p>\n\n\n\n<p>and assumes the operation is simple.<\/p>\n\n\n\n<p>But underneath:<code>GetOrdersAsync() \u2193 GetOrders \u2193 GetCustomerOrders \u2193 GetCustomerOrderDetails \u2193 vw_OrderDetails \u2193 vw_CustomerDetails \u2193 Orders<\/code><\/p>\n\n\n\n<p>The abstraction has successfully hidden the problem.<\/p>\n\n\n\n<p>Unfortunately, it has also hidden the reason the problem exists.<\/p>\n\n\n\n<p>Abstraction is useful.<\/p>\n\n\n\n<p><strong>Too much abstraction can make performance debugging significantly harder.<\/strong><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Measure Before You Optimize<\/h3>\n\n\n\n<p>The most important performance rule is also the least exciting:<\/p>\n\n\n\n<blockquote class=\"wp-block-quote\">\n<p><strong>Measure first.<\/strong><\/p>\n<\/blockquote>\n\n\n\n<p>Don&#8217;t rewrite a stored procedure because it looks ugly.<\/p>\n\n\n\n<p>Don&#8217;t add an index because someone suggested it.<\/p>\n\n\n\n<p>Don&#8217;t add <code>NOLOCK<\/code>.<\/p>\n\n\n\n<p>Don&#8217;t change joins randomly.<\/p>\n\n\n\n<p>Don&#8217;t introduce five temporary tables because you think they might be faster.<\/p>\n\n\n\n<p>Find out where the time is actually going.<\/p>\n\n\n\n<p>Look at things such as:<\/p>\n\n\n\n<ul>\n<li>Actual execution plans<\/li>\n\n\n\n<li>Logical reads<\/li>\n\n\n\n<li>CPU time<\/li>\n\n\n\n<li>Elapsed time<\/li>\n\n\n\n<li>Wait statistics<\/li>\n\n\n\n<li>Blocking<\/li>\n\n\n\n<li>Query duration<\/li>\n\n\n\n<li>Cardinality estimates<\/li>\n\n\n\n<li>Index usage<\/li>\n\n\n\n<li>Statistics<\/li>\n\n\n\n<li>Parameter behavior<\/li>\n\n\n\n<li>Application-generated SQL<\/li>\n\n\n\n<li>Connection pool behavior<\/li>\n<\/ul>\n\n\n\n<p>A query that takes five seconds may not be a SQL problem.<\/p>\n\n\n\n<p>It might be waiting 4.8 seconds for a lock.<\/p>\n\n\n\n<p>Or waiting for a connection.<\/p>\n\n\n\n<p>Or waiting on another query.<\/p>\n\n\n\n<p>Or transferring a massive result set across the network.<\/p>\n\n\n\n<p>Or being executed 500 times.<\/p>\n\n\n\n<p>The stopwatch alone doesn&#8217;t tell you the whole story.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Sometimes the Stored Procedure Isn&#8217;t the Problem<\/h3>\n\n\n\n<p>This is perhaps the most important lesson.<\/p>\n\n\n\n<p>Performance exists across the entire stack.<code>Browser \u2193 ASP.NET Core \u2193 Application Service \u2193 Repository \u2193 Database Driver \u2193 Connection Pool \u2193 SQL Server \u2193 Stored Procedure \u2193 Tables \/ Indexes \/ Storage<\/code><\/p>\n\n\n\n<p>A slow request doesn&#8217;t automatically mean:<code>Stored Procedure = Bad<\/code><\/p>\n\n\n\n<p>It might be:<code>Stored Procedure = 200ms Network = 50ms Application processing = 300ms Serialization = 100ms Waiting for connection = 2 seconds<\/code><\/p>\n\n\n\n<p>Now you&#8217;ve spent three days optimizing a 200ms query.<\/p>\n\n\n\n<p>Excellent work.<\/p>\n\n\n\n<p>Unfortunately, the user is still waiting 2.6 seconds.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">The Best Stored Procedure Is Sometimes No Stored Procedure<\/h3>\n\n\n\n<p>This is not a declaration that stored procedures are bad.<\/p>\n\n\n\n<p>They&#8217;re not.<\/p>\n\n\n\n<p>Stored procedures can be excellent for:<\/p>\n\n\n\n<ul>\n<li>Complex reporting<\/li>\n\n\n\n<li>Database-heavy operations<\/li>\n\n\n\n<li>Security boundaries<\/li>\n\n\n\n<li>Batch operations<\/li>\n\n\n\n<li>Data-intensive transformations<\/li>\n\n\n\n<li>Encapsulating database-specific logic<\/li>\n\n\n\n<li>Operations where reducing network round trips matters<\/li>\n<\/ul>\n\n\n\n<p>But they aren&#8217;t automatically faster simply because they are stored procedures.<\/p>\n\n\n\n<p>A poorly designed stored procedure is still a poorly designed query.<\/p>\n\n\n\n<p>Putting bad SQL inside a stored procedure doesn&#8217;t transform it into good SQL.<\/p>\n\n\n\n<p>It just gives the bad SQL a name.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">So What Should We Actually Do?<\/h3>\n\n\n\n<p>When a stored procedure becomes slow, resist the urge to immediately rewrite everything.<\/p>\n\n\n\n<p>Start with evidence.<\/p>\n\n\n\n<p>Ask:<\/p>\n\n\n\n<ol>\n<li><strong>How slow is it?<\/strong><\/li>\n\n\n\n<li><strong>How often is it executed?<\/strong><\/li>\n\n\n\n<li><strong>Which parameters cause the problem?<\/strong><\/li>\n\n\n\n<li><strong>What does the actual execution plan show?<\/strong><\/li>\n\n\n\n<li><strong>Are estimates wildly different from actual row counts?<\/strong><\/li>\n\n\n\n<li><strong>Are there missing or inappropriate indexes?<\/strong><\/li>\n\n\n\n<li><strong>Are statistics current?<\/strong><\/li>\n\n\n\n<li><strong>Is the query being blocked?<\/strong><\/li>\n\n\n\n<li><strong>Is parameter sniffing involved?<\/strong><\/li>\n\n\n\n<li><strong>Is the application calling it unnecessarily many times?<\/strong><\/li>\n\n\n\n<li><strong>Is it returning far more data than necessary?<\/strong><\/li>\n\n\n\n<li><strong>Would changing the data access pattern solve the problem more effectively?<\/strong><\/li>\n<\/ol>\n\n\n\n<p>Only after answering those questions should you start changing things.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">And Then Someone Says, &#8220;Can We Just Add an Index?&#8221;<\/h3>\n\n\n\n<p>Of course they do.<\/p>\n\n\n\n<p>They always do.<\/p>\n\n\n\n<p>It&#8217;s practically a law of software development.<\/p>\n\n\n\n<blockquote class=\"wp-block-quote\">\n<p>&#8220;The query is slow.&#8221;<\/p>\n<\/blockquote>\n\n\n\n<p>&#8220;Add an index.&#8221;<\/p>\n\n\n\n<blockquote class=\"wp-block-quote\">\n<p>&#8220;The API is slow.&#8221;<\/p>\n<\/blockquote>\n\n\n\n<p>&#8220;Add caching.&#8221;<\/p>\n\n\n\n<blockquote class=\"wp-block-quote\">\n<p>&#8220;The application is slow.&#8221;<\/p>\n<\/blockquote>\n\n\n\n<p>&#8220;Scale it.&#8221;<\/p>\n\n\n\n<blockquote class=\"wp-block-quote\">\n<p>&#8220;The report is slow.&#8221;<\/p>\n<\/blockquote>\n\n\n\n<p>&#8220;Run it at night.&#8221;<\/p>\n\n\n\n<p>These suggestions aren&#8217;t necessarily wrong.<\/p>\n\n\n\n<p>They&#8217;re just not diagnoses.<\/p>\n\n\n\n<p>Performance engineering isn&#8217;t about collecting optimization tricks.<\/p>\n\n\n\n<p>It&#8217;s about understanding <strong>why the system is slow<\/strong>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">The Real Misery<\/h3>\n\n\n\n<p>The real misery of SQL stored procedures isn&#8217;t that SQL Server is complicated.<\/p>\n\n\n\n<p>It&#8217;s that databases are often where years of application decisions eventually meet.<\/p>\n\n\n\n<p>A stored procedure might contain:<\/p>\n\n\n\n<ul>\n<li>15 years of business rules<\/li>\n\n\n\n<li>7 developers&#8217; assumptions<\/li>\n\n\n\n<li>4 reporting requirements<\/li>\n\n\n\n<li>3 emergency fixes<\/li>\n\n\n\n<li>2 abandoned features<\/li>\n\n\n\n<li>1 mysterious cursor<\/li>\n<\/ul>\n\n\n\n<p>And one innocent developer asking:<\/p>\n\n\n\n<blockquote class=\"wp-block-quote\">\n<p>&#8220;Can we just add a column?&#8221;<\/p>\n<\/blockquote>\n\n\n\n<p>That is how a 30-line query becomes a 3,000-line stored procedure.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Final Thoughts<\/h3>\n\n\n\n<p>Stored procedures aren&#8217;t evil.<\/p>\n\n\n\n<p>Indexes aren&#8217;t magic.<\/p>\n\n\n\n<p><code>NOLOCK<\/code> isn&#8217;t a performance button.<\/p>\n\n\n\n<p><code>SELECT *<\/code> isn&#8217;t free.<\/p>\n\n\n\n<p>Cursors aren&#8217;t automatically forbidden.<\/p>\n\n\n\n<p>Temporary tables aren&#8217;t automatically bad.<\/p>\n\n\n\n<p>Dynamic SQL isn&#8217;t automatically bad.<\/p>\n\n\n\n<p>ORMs aren&#8217;t automatically slow.<\/p>\n\n\n\n<p>And adding an interface won&#8217;t make your database faster.<\/p>\n\n\n\n<p>The uncomfortable truth is that <strong>database performance is a system problem<\/strong>.<\/p>\n\n\n\n<p>The SQL matters.<\/p>\n\n\n\n<p>The schema matters.<\/p>\n\n\n\n<p>The indexes matter.<\/p>\n\n\n\n<p>The execution plan matters.<\/p>\n\n\n\n<p>The application matters.<\/p>\n\n\n\n<p>The way the application calls the database matters.<\/p>\n\n\n\n<p>And sometimes the biggest performance improvement isn&#8217;t making the stored procedure execute 20% faster.<\/p>\n\n\n\n<p>It&#8217;s discovering that you didn&#8217;t need to execute it 10,000 times.<\/p>\n\n\n\n<p>So the next time someone says:<\/p>\n\n\n\n<blockquote class=\"wp-block-quote\">\n<p>&#8220;The stored procedure is slow.&#8221;<\/p>\n<\/blockquote>\n\n\n\n<p>Don&#8217;t panic.<\/p>\n\n\n\n<p>Don&#8217;t immediately add an index.<\/p>\n\n\n\n<p>Don&#8217;t add <code>NOLOCK<\/code>.<\/p>\n\n\n\n<p>Don&#8217;t blame Entity Framework.<\/p>\n\n\n\n<p>Don&#8217;t rewrite 4,000 lines of SQL.<\/p>\n\n\n\n<p>Take a breath.<\/p>\n\n\n\n<p>Look at the execution plan.<\/p>\n\n\n\n<p>Measure the actual behavior.<\/p>\n\n\n\n<p>Find the bottleneck.<\/p>\n\n\n\n<p>And remember:<\/p>\n\n\n\n<p><strong>SQL Server isn&#8217;t necessarily having a performance problem.<\/strong><\/p>\n\n\n\n<p><strong>It may simply be faithfully executing everything we&#8217;ve spent the last fifteen years asking it to do.<\/strong><\/p>\n","protected":false},"excerpt":{"rendered":"<p>There is a special kind of sadness that comes from being told: &#8220;The stored procedure is slow.&#8221; At first, you remain optimistic. You open SQL Server Management Studio. You run the procedure. It takes 14 seconds. Okay. That&#8217;s not great, but 14 seconds isn&#8217;t the end of civilization. Then someone tells you: &#8220;It normally takes&hellip;&nbsp;<a href=\"https:\/\/twobitranch.io\/index.php\/2024\/03\/14\/sql-stored-procedure-performance-misery\/\" class=\"\" rel=\"bookmark\">Read More &raquo;<span class=\"screen-reader-text\">SQL Stored procedure performance misery<\/span><\/a><\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"neve_meta_sidebar":"","neve_meta_container":"","neve_meta_enable_content_width":"","neve_meta_content_width":0,"neve_meta_title_alignment":"","neve_meta_author_avatar":"","neve_post_elements_order":"","neve_meta_disable_header":"","neve_meta_disable_footer":"","neve_meta_disable_title":"","footnotes":""},"categories":[1],"tags":[],"_links":{"self":[{"href":"https:\/\/twobitranch.io\/index.php\/wp-json\/wp\/v2\/posts\/52"}],"collection":[{"href":"https:\/\/twobitranch.io\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/twobitranch.io\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/twobitranch.io\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/twobitranch.io\/index.php\/wp-json\/wp\/v2\/comments?post=52"}],"version-history":[{"count":2,"href":"https:\/\/twobitranch.io\/index.php\/wp-json\/wp\/v2\/posts\/52\/revisions"}],"predecessor-version":[{"id":65,"href":"https:\/\/twobitranch.io\/index.php\/wp-json\/wp\/v2\/posts\/52\/revisions\/65"}],"wp:attachment":[{"href":"https:\/\/twobitranch.io\/index.php\/wp-json\/wp\/v2\/media?parent=52"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/twobitranch.io\/index.php\/wp-json\/wp\/v2\/categories?post=52"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/twobitranch.io\/index.php\/wp-json\/wp\/v2\/tags?post=52"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}