{"id":25363565,"url":"https://github.com/tcd93/writing-better-sql","last_synced_at":"2025-08-18T05:48:54.945Z","repository":{"id":196466787,"uuid":"696084680","full_name":"tcd93/writing-better-SQL","owner":"tcd93","description":"Understanding how to write more performant sequels","archived":false,"fork":false,"pushed_at":"2023-09-25T07:19:20.000Z","size":258,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"master","last_synced_at":"2025-04-09T04:19:56.238Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/tcd93.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null}},"created_at":"2023-09-25T03:43:23.000Z","updated_at":"2023-09-25T07:21:10.000Z","dependencies_parsed_at":null,"dependency_job_id":"8242fad7-c9ee-4ee6-bfa7-40477f264690","html_url":"https://github.com/tcd93/writing-better-SQL","commit_stats":null,"previous_names":["tcd93/writing-better-sql"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/tcd93/writing-better-SQL","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tcd93%2Fwriting-better-SQL","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tcd93%2Fwriting-better-SQL/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tcd93%2Fwriting-better-SQL/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tcd93%2Fwriting-better-SQL/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/tcd93","download_url":"https://codeload.github.com/tcd93/writing-better-SQL/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tcd93%2Fwriting-better-SQL/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":270951278,"owners_count":24674006,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","status":"online","status_checked_at":"2025-08-18T02:00:08.743Z","response_time":89,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2025-02-14T22:35:03.212Z","updated_at":"2025-08-18T05:48:54.913Z","avatar_url":"https://github.com/tcd93.png","language":"Ruby","funding_links":[],"categories":[],"sub_categories":[],"readme":"# MSSQL Better SQL\nAn article is about how to control the plan optimizer to take better, more performant paths\n\nYou should have knowledge about [operators](https://tcd93.github.io/MSSQL-execution-plan/) first\n\n---\n# Table of contents\n- [Avoid Order](#avoid-order)\n- [Avoid Spool / Big Loop](#avoid-spool--big-loop)\n- [Filtering: Inner Join vs. Exists](#inner-join-vs.-exists-for-filtering)\n- [Summary](#summary)\n\n---\n\n# Avoid Order\n\n## Scenario\nWe have an slow SQL with the following format\n```sql\nSELECT … FROM #X \n    LEFT JOIN #Y ON … \n    LEFT JOIN #Z ON … \n    GROUP BY X.A, Y.B, Z.C\n\nUNION ALL\n\nSELECT … FROM #X \n    LEFT JOIN #Y ON … \n    GROUP BY X.A, Y.B\n\nORDER BY X.A, Y.B, Z.C\n```\n\nHere is the execution plan:\n\n![](img/plan1.png)\n\nThe optimizer opted for the Merge Join execution plan as it sees it is the better option than Nested Loop or Hash Join.\n\nSince the inner query is rather complicated, indexes can not be used, thus a sort operation has to be added in order for Merge join to work.\n\nNote that in the above example, the sort operation was actually for stream aggregate (Group By) function, the Merge Join reused the results, but still, a sort is needed nevertheless.\n\nWe have two ways to make this better:\n1. Remove `order`\n2. Rewrite the query\n\n### Remove Order\nThis is the plan after remove the `ORDER BY`:\n![](img/plan2.png)\n\nIt opted for the Hash Match (Aggregate) in the bottom path instead of Sort + Stream Aggregate which removed the high I/O cost from the sort operation (the more rows it has, the more I/O it needs); Merge Join is also replaced with Concatenation.\n\nIn return, hash functions require more CPU power, but this trade-off is acceptable \u0026 more balanced:\n![](img/plan3.png)\n\n#### When can we remove it?\n`Order By` is a very costly operation if there is no index available to cover it.\n\nIf there is no need to display data set in an ordered fashion (ex: ad-hoc query), we should remove it.\n\nIf we're providing paginated result to display on web application, we should remove it, let application code handle the much smaller dataset's ordering.\n\nAlso there’s always a probability that a developer used “order by” to debug / test their data and forgot to remove it afterwards 😁\n\n#### What if we can't?\nConsider these options:\n- Use a covering index\n- Optimize inner queries (between Unions)\n\n#### Use Index\nStrem aggregate \u0026 merge join operators are one of the most effective physical operators in MSSQL _if the inputs are already sorted_.\n\nWe can exploit this fact by combining them with an index.\n\nFor a simple query such as:\n```sql\nSELECT A, SUM(Metrics) FROM #X GROUP BY A\n```\n\nA clustered index on column A would be enough to speed up this query because the optimizer can use Stream Aggregate on the already ordered index data as inputs.\n\nBut a more complicated one like this:\n```sql\nSELECT X.A, Y.B, SUM(Metrics) \nFROM #X \nJOIN #Y ON …  \nGROUP BY X.A, Y.B\nSORT BY X.A, Y.B\n```\nNo indexes can be used, as the sorting needs to be done on the joined result from #X and #Y. Optimizing this type of SQL using the “traditional” method is very hard (if not impossible)\n\nLet’s go back to the [original query](#scenario)\n![](img/plan1.png)\n\nNotice these branches use the same anchor table #X, but in the original plan the executor just run sort operation on every branch, so there were many wasted effort sorting the same (or almost the same) data set.\n\nWhat we can do is to extract the data set that “encompass” all others in the unions out to a temporary table (simply put - the query that produce the most rows):\n\n```sql\nSELECT X.A, Y.B, Measures…\nINTO #mergedTmp\nFROM X LEFT JOIN Y ON … \n\tLEFT JOIN Z ON … \n\nCREATE CLUSTERED INDEX I1 ON #mergedTmp (A, B)\n```\n\nAnd then rewrite the original SQL using the #mergedTmp and see result:\n![](img/plan4.png)\n\nThere is no more sorting before aggregation, and compare with the original plan, the cost is almost 4 times lower.\n![](img/plan5.png)\n\nOf course this is just comparing the two individual SQLs, this method is going to require some overhead to actually populate the temporary data, but the more UNIONS of the same type you have, the more efficient this is going to be.\n\nIn the above example, we can spot the “anchor” data, but if we got really unlucky and there’s no correlation whatsoever between each union block, then we’d just have to optimize the individual blocks separately.\n\n# Avoid Spool / Big Loop\n\n## Scenario\nWe have an SQL that retrieves the last 4 transactions associated with a customer, the following takes more than 7 seconds to run on my machine:\n```sql\nSELECT transdate, username, amount\nFROM customer c \nCROSS APPLY (\n\tSELECT TOP 4 transdate, winlost\n\tFROM trans t\n\tWHERE t.custid = c.custid AND t.custid = 1340408\n\tORDER BY transdate DESC\n) AS T\n```\n\n![](img/plan6.png)\nAt first glance we notice the Sort operator taking 95% of the total cost (again, the Sort). So we’d create an index on transdate column and problem solved?\n\nNot so fast, if we're gonna create a new index each time we encounter a slow SQL then our database will be flooded. Let's check the existing ones first:\n- There is already an index of column CustID on the transaction table called IX_CustId\n- There is also an index of CustID on Customer table\n\nSo something is not right, the optimizer is not using them despite the query is joining on that column.\n\n### What's wrong?\nThere is a big fat arrow from the index scan to the index spool, meaning it is storing millions of rows of transaction data into a temporary index, which is overkill for just 1 customer\n![](img/plan7.png)\n\nThere is a problem with CROSS APPLY, the optimizer do not “see into” each block, it just optimize each of them separately.\n\nTo fix this, specifically tell the optimizer we need just one customer here:\n```diff\nSELECT transdate, username, amount\n- FROM customer c\n+ FROM (SELECT custid, username FROM customer WHERE custid = 1340408) c\nCROSS APPLY (\n\tSELECT TOP 4 transdate, winlost\n\tFROM trans t\n-   WHERE t.custid = c.custid AND t.custid = 1340408\n+   WHERE t.custid = c.custid\n\tORDER BY transdate DESC\n ) AS T\n```\n\nThis time the index is properly used, and took 0 second, we can ignore the Key lookup for now, the plan is “good enough”\n![](img/plan8.png)\n\nWhat if we need multiple customers instead of one? It became slow again (7 seconds)\n```sql\nSELECT transdate, username, amount\nFROM (\n    SELECT custid, username FROM customer WHERE custid BETWEEN 1340408 AND 2000000\n) c\nCROSS APPLY (\n\tSELECT TOP 4 transdate, winlost\n\tFROM trans t\n    WHERE t.custid = c.custid\n\tORDER BY transdate DESC\n) AS T\n```\n\nThe Spool also goes back, this query returns 422 rows which is just about 100 customers\n![](img/plan9.png)\n\nLet’s check how many _actual_ customer is from ID 1340000 - 2000000: \n```sql\nSELECT COUNT(1) FROM customer WHERE custid BETWEEN 1340408 AND 2000000\n```\n\nThis returns about 10,000 rows, so the density is very low (100 / 10000 = 0.001). Meaning: _there are many registered customers, but very few of them actually made transactions._\n\nThe query optimizer does not know how many customers are IN the transaction table with the ID from 1340000 to 2000000, so it opted for a “safe” way that is to scan the entire transaction table, where in fact there is just 0.001 hit rate per scan\n\nAgain, add another condition to pre-filter data before passing them into inner loop:\n```diff\nSELECT transdate, username, amount\nFROM (\n    SELECT custid, username FROM customer WHERE custid BETWEEN 1340408 AND 2000000\n+      AND EXISTS (SELECT 1 FROM trans t WHERE t.custid = customer.custid) \n ) c\nCROSS APPLY (\n\tSELECT TOP 4 transdate, winlost\n\tFROM trans t\n    WHERE t.custid = c.custid\n\tORDER BY transdate DESC\n ) AS T\n```\n\nThe data is filtered before passing into the inner loop using a Merge join, so the amount of loops it has to do is very low, thus near-instant execution time again:\n![](img/plan10.png)\n\n### Re-writing the query\nThere’s a more generic way to achieve this result without using CROSS APPLY, that is by window function `ROW_NUMBER`\n\n```sql\nSELECT transdate, username, winlost\nFROM (\n\tSELECT transdate, c.username, winlost,\n\t\trow_number() over (partition by c.custid order by t.transdate desc) rn\n\tFROM customer c\n\tINNER JOIN trans t\n\t\tON c.custid = t.custid\n\tWHERE c.custid between 1340000 and 2000000\n) AS s\nWHERE rn \u003c= 4\n```\n\n![](img/plan11.png)\nVery straightforward, data arrows do not get trimmed down until it reached the last filter operation. This query is executed instantly, but at a much higher CPU cost than the previous one due to the Sort:\n\n![](img/plan12.png)\n\n### Why avoid Spool?\nSpools (table spool / index spool) are a way for the optimizer to improve your SQL. But, it comes with a high cost (storing data in tempdb). Also, when data density is low, then lazy-loading practically becomes useless and just hinder the performance even more.\n\nTechnically they are not bad per-se, but it certainly does imply a better execution plan can possibly be achieved without them (except for special queries such as Recursive CTE, HierarchyID…)\n\n### Why avoid Big Loop?\nNested Loop Join is the most efficient physical join operator when the outer input is small compared to the (indexed) inner input (low CPU \u0026 IO cost, even sometimes better than Merge Join)\n\nBut since it has a Big-O of O(n\u003csup\u003e2\u003c/sup\u003e) or O(n.log(m)), the performance degrade is terrible when we select more data from the outer loop. That’s why it is very good in OLTP systems, but not so much compared to Hash / Merge joins in OLAP systems.\n\n# Inner Join vs. Exists for filtering\nLet's say we do some __filtering__ on a very big table:\n```sql\nSELECT ...\nFROM BigFact\nINNER JOIN #InputArray\n```\n![](img/plan13.png)\n\nThe estimated number is way off actual despite updated stats.\n\nThis is because Optimizer does not know about the relationship between the BigTable and the #InputArray, so it goes for a generic Hash join, which is good enough but uses a lot of memory.\n\nAdding an index achieve better statistics, but the memory grant is still greater than what is actually needed:\n\n![](img/plan14.png)\n\nNow rewrite the query using `EXISTS`:\n```sql\nSELECT ...\nFROM BigFact\nWHERE EXISTS (SELECT 1 FROM #InputArray WHERE ...)\n```\n![](img/plan15.png)\n\nWe specifically tell the Optimizer the relationship between the BigTable and #InputArray is at most many-to-one by using EXISTS clause.\n\nPlan now uses right semi join which preserve all rows of BigTable, the estimated number can now only be lower than the actual rows of BigTable.\n\n# Summary\n* Don’t jump straight into the warning signs and try to optimize individual operators; don’t try to reduce  the cost of an operator, examine why it has high cost in the first place\n* Understand how the optimizer works (it’s not as smart as you think, provide it with more information), high cost doesn’t mean it’s bad, maybe it’s just inevitable\n* Look at the big picture, go from bigger to smaller; look at the size of arrows, watch out for the big ones, some operators don’t work well with big arrows\n* Check for spools / sort operators, try to rewrite the query without them\n* Update statistics frequently\n* To filter data, try WHERE EXISTS instead of INNER JOIN\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftcd93%2Fwriting-better-sql","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ftcd93%2Fwriting-better-sql","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftcd93%2Fwriting-better-sql/lists"}