{"id":79473,"date":"2018-06-27T13:57:52","date_gmt":"2018-06-27T13:57:52","guid":{"rendered":"https:\/\/www.red-gate.com\/simple-talk\/?p=79473"},"modified":"2026-03-12T15:39:12","modified_gmt":"2026-03-12T15:39:12","slug":"t-sql-window-functions-and-performance","status":"publish","type":"post","link":"https:\/\/www.red-gate.com\/simple-talk\/databases\/sql-server\/t-sql-programming-sql-server\/t-sql-window-functions-and-performance\/","title":{"rendered":"T-SQL Window Functions: Performance, NTILE &#038; Framing"},"content":{"rendered":"\n<p>T-SQL window functions (ROW_NUMBER, RANK, DENSE_RANK, NTILE, LAG, LEAD, SUM OVER, etc.) can create performance bottlenecks if you don\u2019t understand sorting. Every window function with ORDER BY in the OVER clause requires SQL Server to sort the data &#8211; each different OVER clause may require a separate sort. The keys to performance: (1) align OVER clause ORDER BY with existing index order to eliminate sorts, (2) understand framing (ROWS vs. RANGE) because RANGE is the default and requires extra work, (3) avoid multiple OVER clauses with different ORDER BY columns, and (4) pre-aggregate for unsupported functions like COUNT(DISTINCT).<\/p>\n\n\n\n<p>T-SQL window functions make writing many queries easier, and they often provide better performance as well over older techniques. For example, using the <strong>LAG<\/strong> function is so much better than doing a self-join. To get better performance overall, however, you need to understand the concept of framing and how window functions rely on sorting to provide the results.<\/p>\n\n\n\n<p><em>NOTE: See my <a href=\"https:\/\/www.red-gate.com\/simple-talk\/sql\/t-sql-programming\/the-performance-of-window-aggregates-revisited-with-sql-server-2019\/\">new article<\/a> to learn how improvements to the optimizer in 2019 affect performance! <\/em><\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"h-the-over-clause-and-sorting\">The OVER Clause and Sorting<\/h2>\n\n\n\n<p>There are two options in the <strong>OVER<\/strong> clause that can cause sorting: <strong>PARTITION BY<\/strong> and <strong>ORDER BY<\/strong>. <strong>PARTITION BY<\/strong> is supported by all window functions, but it\u2019s optional. The <strong>ORDER BY<\/strong> is required for most of the functions. Depending on what you are trying to accomplish, the data will be sorted based on the <strong>OVER<\/strong> clause, and that could be the performance bottleneck of your query.<\/p>\n\n\n\n<p>The <strong>ORDER BY<\/strong> option in the <strong>OVER <\/strong>clause is required so that the database engine can line up the rows, so to speak, in order to apply the function in the correct order. For example, say you want the <strong>ROW_NUMBER<\/strong> function to be applied in order of <strong>SalesOrderID<\/strong>. The results will look different than if you want the function applied in order of <strong>TotalDue<\/strong> in descending order. Here is an example:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">USE AdventureWorks2017; --or whichever version you have\nGO\nSELECT SalesOrderID, \n\tTotalDue, \n\tROW_NUMBER() OVER(ORDER BY SalesOrderID) AS RowNum\nFROM Sales.SalesOrderHeader;\n\nSELECT SalesOrderID, \n\tTotalDue, \n\tROW_NUMBER() OVER(ORDER BY TotalDue DESC) AS RowNum\nFROM Sales.SalesOrderHeader;<\/pre>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"707\" height=\"518\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2018\/06\/word-image-114.png\" alt=\"\" class=\"wp-image-79474\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>Since the first query is using the cluster key as the <strong>ORDER BY<\/strong> option, no sorting is necessary.<\/p>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"1458\" height=\"134\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2018\/06\/word-image-115.png\" alt=\"\" class=\"wp-image-79475\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>The second query has an expensive sort operation.<\/p>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"1430\" height=\"129\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2018\/06\/word-image-116.png\" alt=\"\" class=\"wp-image-79476\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>The <strong>ORDER BY<\/strong> in the <strong>OVER<\/strong> clause is not connected to the <strong>ORDER BY<\/strong> clause added to the overall query which could be quite different. Here is an example showing what happens if the two are different:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">SELECT SalesOrderID, \n\tTotalDue, \n\tROW_NUMBER() OVER(ORDER BY TotalDue DESC) AS RowNum\nFROM Sales.SalesOrderHeader\nORDER BY SalesOrderID;<\/pre>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"701\" height=\"280\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2018\/06\/word-image-117.png\" alt=\"\" class=\"wp-image-79477\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>&nbsp;The clustered index key is <strong>SalesOrderID<\/strong>, but the rows must first be sorted by <strong>TotalDue<\/strong> in descending order and then back to <strong>SalesOrderID<\/strong>. Take a look at the execution plan:<\/p>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"1428\" height=\"144\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2018\/06\/word-image-118.png\" alt=\"\" class=\"wp-image-79478\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>The <strong>PARTITION BY<\/strong> clause, supported but optional, for all T-SQL window functions also causes sorting. It\u2019s similar to, but not exactly like, the <strong>GROUP BY<\/strong> clause for aggregate queries. This example starts the row numbers over for each customer.<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">SELECT CustomerID,\n\tSalesOrderID, \n\tTotalDue, \n\tROW_NUMBER() OVER(PARTITION BY CustomerID ORDER BY SalesOrderID) \n          AS RowNum\nFROM Sales.SalesOrderHeader;<\/pre>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"900\" height=\"468\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2018\/06\/word-image-119.png\" alt=\"\" class=\"wp-image-79479\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>The execution plan shows just one sort operation, a combination of <strong>CustomerID<\/strong> and <strong>SalesOrderID<\/strong>.<\/p>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"1660\" height=\"185\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2018\/06\/word-image-120.png\" alt=\"\" class=\"wp-image-79480\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>The only way to overcome the performance impact of sorting is to create an index specifically for the <strong>OVER<\/strong> clause. In his book <a href=\"https:\/\/www.amazon.com\/Microsoft-High-Performance-Functions-Developer-Reference\/dp\/0735658366\/ref=sr_1_3?ie=UTF8&amp;qid=1530031235&amp;sr=8-3\">Microsoft SQL Server 2012 High-Performance T-SQL Using Window Functions<\/a>, Itzik Ben-Gan recommends the POC index. POC stands for (<strong>P)ARTITION BY<\/strong>, (<strong>O)RDER BY<\/strong>, and (c)overing. He recommends adding any columns used for filtering before the <strong>PARTITION BY<\/strong> and <strong>ORDER BY<\/strong> columns in the key. Then add any additional columns needed to create a covering index as included columns. Just like anything else, you will need to test to see how such an index impacts your query and overall workload. Of course, you cannot add an index for every query that you write, but if the performance of a particular query that uses a window function is important, you can try out this advice.<\/p>\n\n\n\n<p>Here is an index that will improve the previous query:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">CREATE NONCLUSTERED INDEX test ON Sales.SalesOrderHeader\n(CustomerID, SalesOrderID) \nINCLUDE (TotalDue);<\/pre>\n\n\n\n<p>When you rerun the query, the sort operation is now gone from the execution plan:<\/p>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"1138\" height=\"141\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2018\/06\/word-image-121.png\" alt=\"\" class=\"wp-image-79481\"\/><\/figure>\n\n\n\n<p><strong>Read Also: <\/strong><a href=\"https:\/\/www.red-gate.com\/simple-talk\/databases\/sql-server\/t-sql-programming-sql-server\/sql-server-closure-tables\/\">Closure tables for hierarchical queries<\/a><\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"h-framing\">Framing<\/h2>\n\n\n\n<p>In my opinion, framing is the most difficult concept to understand when learning about T-SQL window functions. To learn more about the syntax see <a href=\"https:\/\/www.red-gate.com\/simple-talk\/sql\/t-sql-programming\/introduction-to-t-sql-window-functions\/\">introduction to T-SQL window functions<\/a>. Framing is required for the following:<\/p>\n\n\n<div class=\"block-core-list\">\n<ul class=\"wp-block-list\">\n<li>Window aggregates with the ORDER by, used for running totals or moving averages, for example<\/li>\n\n\n\n<li>FIRST_VALUE<\/li>\n\n\n\n<li>LAST_VALUE<\/li>\n<\/ul>\n<\/div>\n\n\n<p>Luckily, framing is not required most of the time, but unfortunately, it\u2019s easy to skip the frame and use the default. The default frame is always <strong>RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW<\/strong>. While you will get the correct results as long as the <strong>ORDER BY<\/strong> option consists of a unique column or set of columns, you will see a performance hit.<\/p>\n\n\n\n<p>Here is an example comparing the default frame to the correct frame:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">SET STATISTICS IO ON;\nGO\nSELECT CustomerID, \n\tSalesOrderID, \n\tTotalDue, \n\tSUM(TotalDue) OVER(PARTITION BY CustomerID ORDER BY SalesOrderID)\n          AS RunningTotal \nFROM Sales.SalesOrderHeader;\n\nSELECT CustomerID, \n\tSalesOrderID, \n\tTotalDue, \n\tSUM(TotalDue) OVER(PARTITION BY CustomerID ORDER BY SalesOrderID\n\t   ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) \n           AS RunningTotal \nFROM Sales.SalesOrderHeader;<\/pre>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"953\" height=\"701\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2018\/06\/word-image-122.png\" alt=\"\" class=\"wp-image-79482\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>The results are the same, but the performance is very different. Unfortunately, the execution plan doesn\u2019t tell you the truth in this case. It reports that each query took 50% of the resources:<\/p>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"1441\" height=\"353\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2018\/06\/word-image-123.png\" alt=\"\" class=\"wp-image-79483\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>If you review the statistics IO values, you will see the difference:<\/p>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"1565\" height=\"467\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2018\/06\/word-image-124.png\" alt=\"\" class=\"wp-image-79484\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>Using the correct frame is even more important if your <strong>ORDER BY<\/strong> option is not unique or if you are using <strong>LAST_VALUE<\/strong>. In this example, the <strong>ORDER BY<\/strong> column is <strong>OrderDate<\/strong>, but some customers have placed more than one order on a given date. When not specifying the frame, or using RANGE, the function treats matching dates as part of the same window.<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">SELECT CustomerID, \n\tSalesOrderID, \n\tTotalDue, \n\tOrderDate,\n\tSUM(TotalDue) OVER(PARTITION BY CustomerID ORDER BY OrderDate) \n           AS RunningTotal,\n\tSUM(TotalDue) OVER(PARTITION BY CustomerID ORDER BY OrderDate\n\t   ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) \n              AS CorrectRunningTotal \nFROM Sales.SalesOrderHeader\nWHERE CustomerID IN ('11433','11078','18758');<\/pre>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"1719\" height=\"664\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2018\/06\/word-image-125.png\" alt=\"\" class=\"wp-image-79485\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>The reason for the discrepancy is that <strong>RANGE<\/strong> sees the data logically while <strong>ROWS <\/strong>sees it positionally. There are two solutions for this problem. One is to make sure that the <strong>ORDER BY<\/strong> option is unique. The other and more important option is to always specify the frame where it\u2019s supported.<\/p>\n\n\n\n<p>The other place that framing causes logical problems is with <strong>LAST_VALUE<\/strong>. <strong>LAST_VALUE<\/strong> returns an expression from the last row of the frame. Since the default frame (<strong>RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW<\/strong>) only goes up to the current row, the last row of the frame is the row where the calculation is being performed. Here is an example:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">SELECT CustomerID, \n\tSalesOrderID, \n\tTotalDue, \n\tLAST_VALUE(SalesOrderID) OVER(PARTITION BY CustomerID \n                ORDER BY SalesOrderID) AS LastOrderID, \n\tLAST_VALUE(SalesOrderID) OVER(PARTITION BY CustomerID \n                ORDER BY SalesOrderID\n\t\tROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) \n                AS CorrectLastOrderID\nFROM Sales.SalesOrderHeader\nORDER BY CustomerID, SalesOrderID;<\/pre>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"1248\" height=\"511\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2018\/06\/LAST_VALUE.png\" alt=\"\" class=\"wp-image-79512\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"h-window-aggregates\">Window Aggregates<\/h2>\n\n\n\n<p>One of the handiest feature of T-SQL window functions is the ability to add an aggregate expression to a non-aggregate query. Unfortunately, this can often perform poorly. To see the problem, you need to look at the statistics IO results where you will see a large number of logical reads. My advice when you need to return values at different granularities within the same query for a large number of rows is to use one of the older techniques, such as a common table expression (CTE), temp table, or even a variable. If it\u2019s possible to pre-aggregate before using the window aggregate, that is another option. Here is an example that shows the difference between a window aggregate and another technique:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">SELECT SalesOrderID, \n\tTotalDue, \n\tSUM(TotalDue) OVER() AS OverallTotal\nFROM Sales.SalesOrderHeader\nWHERE YEAR(OrderDate) =2013;\n\nDECLARE @OverallTotal MONEY;\n \nSELECT @OverallTotal = SUM(TotalDue) \nFROM Sales.SalesOrderHeader\nWHERE YEAR(OrderDate) = 2013;\n\nSELECT SalesOrderID, \n\tTotalDue, \n\t@OverallTotal AS OverallTotal\nFROM Sales.SalesOrderHeader AS SOH \nWHERE YEAR(OrderDate) = 2013;<\/pre>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"1493\" height=\"558\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2018\/06\/word-image-127.png\" alt=\"\" class=\"wp-image-79487\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>The first query only scans the table once, but it has 28,823 logical reads in a worktable. The second method scans the table twice, but it doesn\u2019t need the worktable.<\/p>\n\n\n\n<p>The next example uses a windows aggregate applied to an aggregate expression:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">SELECT YEAR(OrderDate) AS OrderYear,\n\tSUM(TotalDue) AS YearTotal, \n\tSUM(TotalDue)\/\n\tSUM(SUM(TotalDue)) OVER() * 100 AS PercentOfSales \nFROM Sales.SalesOrderHeader\nGROUP BY YEAR(OrderDate)\nORDER BY OrderYear;<\/pre>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"811\" height=\"281\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2018\/06\/word-image-128.png\" alt=\"\" class=\"wp-image-79488\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>When using window functions in an aggregate query, the expression must follow the same rules as the <strong>SELECT<\/strong> and <strong>ORDER BY<\/strong> clauses. In this case, the window function is applied to <strong>SUM(TotalDue)<\/strong>. It looks like a nested aggregate, but it\u2019s really a window function applied to an aggregate expression.<\/p>\n\n\n\n<p>Since the data has been aggregated before the window function was applied, the performance is good:<\/p>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"1631\" height=\"229\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2018\/06\/word-image-129.png\" alt=\"\" class=\"wp-image-79489\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>There is one more interesting thing to know about using window aggregates. If you use multiple expressions that use matching <strong>OVER<\/strong> clause definitions, you will not see an additional degradation in performance.<\/p>\n\n\n\n<p>My advice is to use this functionality with caution. It\u2019s quite handy but doesn\u2019t scale that well.<\/p>\n\n\n\n<p><strong>Read also: <\/strong><a href=\"https:\/\/www.red-gate.com\/simple-talk\/databases\/sql-server\/t-sql-programming-sql-server\/using-a-subquery-in-a-select-statement\/\">Subqueries as alternatives to window functions<\/a><\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"h-performance-comparisons\">Performance Comparisons<\/h2>\n\n\n\n<p>The examples presented so far have used the small <strong>Sales.SalesOrderHeader<\/strong> table from AdventureWorks and reviewed the execution plans and logical reads. In real life, your customers will not care about the execution plan or the logical reads; they will care about how fast the queries run. To better see the difference in run times, I used Adam Machanic\u2019s <a href=\"http:\/\/dataeducation.com\/thinking-big-adventure\/\">Thinking Big (Adventure) script<\/a> with a twist.<\/p>\n\n\n\n<p>The script creates a table called <strong>bigTransactionHistory<\/strong> containing over 30 million rows. After running Adam\u2019s script, I created two more copies of his table, with 15 and 7.5 million rows respectively. I also turned on the <em>Discard results after execution<\/em> property in the Query Editor so that populating the grid did not affect the run times. I ran each test three times and cleared the buffer cache before each run.<\/p>\n\n\n\n<p>Here is the script to create the extra tables for the test:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">SELECT TOP(50) Percent * \nINTO mediumTransactionHistory\nFROM bigTransactionHistory;\nSELECT TOP(25) PERCENT * \nINTO smallTransactionHistory\nFROM bigTransactionHistory;\nGO\nALTER TABLE mediumTransactionHistory\nALTER COLUMN TransactionID INT NOT NULL;\nGO\nALTER TABLE mediumTransactionHistory\nADD CONSTRAINT pk_mediumTransactionHistory PRIMARY KEY (TransactionID);\nGO\nALTER TABLE smallTransactionHistory\nALTER COLUMN TransactionID INT NOT NULL;\nGO\nALTER TABLE smallTransactionHistory\nADD CONSTRAINT pk_smallTransactionHistory PRIMARY KEY (TransactionID);\nGO\nCREATE NONCLUSTERED INDEX IX_ProductId_TransactionDate\nON mediumTransactionHistory\n(\n\tProductId,\n\tTransactionDate\n)\nINCLUDE \n(\n\tQuantity,\n\tActualCost\n);\nCREATE NONCLUSTERED INDEX IX_ProductId_TransactionDate\nON smallTransactionHistory\n(\n\tProductId,\n\tTransactionDate\n)\nINCLUDE \n(\n\tQuantity,\n\tActualCost\n);<\/pre>\n\n\n\n<p>I can\u2019t say enough about how important it is to use the frame when it\u2019s supported. To see the difference, I ran a test to calculate running totals using four methods:<\/p>\n\n\n<div class=\"block-core-list\">\n<ul class=\"wp-block-list\">\n<li>Cursor solution<\/li>\n\n\n\n<li>Correlated sub-query<\/li>\n\n\n\n<li>Window function with default frame<\/li>\n\n\n\n<li>Window function with ROWS<\/li>\n<\/ul>\n<\/div>\n\n\n<p>I ran the test on the three new tables. Here are the results in a chart format:<\/p>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"1647\" height=\"611\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2018\/06\/word-image-130.png\" alt=\"\" class=\"wp-image-79490\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>When running with the <strong>ROWS<\/strong> frame, the 7.5 million row table took less than a second to run on the system I was using when performing the test. The 30 million row table took about one minute to run.<\/p>\n\n\n\n<p>Here is the query using the <strong>ROWS<\/strong> frame against the 30 million row table:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">SELECT ProductID, SUM(ActualCost) OVER(PARTITION BY ProductID \n   ORDER BY TransactionDate \n   ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) \n    AS RunningTotal\nFROM bigTransactionHistory;<\/pre>\n\n\n\n<p>I also performed a test to see how window aggregates performed compared to traditional techniques. In this case, I used just the 30 million row table, but performed one, two, or three calculations using the same granularity and, therefore, same <strong>OVER<\/strong> clause. I compared the window aggregate performance to a CTE and to a correlated subquery.<\/p>\n\n\n\n<figure class=\"wp-block-image\"><img loading=\"lazy\" decoding=\"async\" width=\"1727\" height=\"726\" src=\"https:\/\/www.red-gate.com\/simple-talk\/wp-content\/uploads\/2018\/06\/word-image-131.png\" alt=\"\" class=\"wp-image-79491\"\/><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<p>The window aggregate performed the worst, about 1.75 minutes in each case. The CTE performed the best when increasing the number of calculations since the table was just touched once for all three. The correlated subquery performed worse when increasing the number of calculations since each calculation had to run separately, and it touched the table a total of four times.<\/p>\n\n\n\n<p>Here is the winning query:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">WITH Calcs AS (<br>\tSELECT ProductID,<br>\t\tAVG(ActualCost) AS AvgCost, <br>\t\tMIN(ActualCost) AS MinCost,<br>\t\tMAX(ActualCost) AS MaxCost<br>\tFROM bigTransactionHistory<br>\tGROUP BY ProductID)<br>SELECT O.ProductID, <br>\tActualCost,<br>\tAvgCost, <br>\tMinCost,<br>\tMaxCost <br>FROM bigTransactionHistory AS O <br>JOIN Calcs ON O.ProductID = Calcs.ProductID;<\/pre>\n\n\n\n<p><strong>Read also: <\/strong><a href=\"https:\/\/www.red-gate.com\/simple-talk\/databases\/sql-server\/t-sql-programming-sql-server\/what-are-columnstore-indexes\/\">Columnstore indexes for analytical queries<\/a><\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"h-conclusion\">Conclusion<\/h2>\n\n\n\n<p>T-SQL window functions have been promoted as being great for performance. In my opinion, they make writing queries easier, but you need to understand them well to get good performance. Indexing can make a difference, but you can\u2019t create an index for every query you write. Framing may not be easy to understand, but it is so important if you need to scale up to large tables.<\/p>\n\n\n\n<p><strong>Read also:\u00a0<\/strong><a href=\"https:\/\/www.red-gate.com\/simple-talk\/databases\/sql-server\/t-sql-programming-sql-server\/temporary-tables-in-sql-server\/\">T<span data-sheets-root=\"1\">emporary tables for pre-aggregation<\/span><\/a><\/p>\n\n\n\n<section id=\"my-first-block-block_14989f4c55a4a2805a3d6d85f5cb5a96\" class=\"my-first-block alignwide\">\n    <div class=\"bg-brand-600 text-base-white py-5xl px-4xl rounded-sm bg-gradient-to-r from-brand-600 to-brand-500 red\">\n        <div class=\"gap-4xl items-start md:items-center flex flex-col md:flex-row justify-between\">\n            <div class=\"flex-1 col-span-10 lg:col-span-7\">\n                <h3 class=\"mt-0 font-display mb-2 text-display-sm\">Fast, reliable and consistent SQL Server development&#8230;<\/h3>\n                <div class=\"child:last-of-type:mb-0\">\n                                            &#8230;with SQL Toolbelt Essentials. 10 ingeniously simple tools for accelerating development, reducing risk, and standardizing workflows.                                    <\/div>\n            <\/div>\n                                            <a href=\"https:\/\/www.red-gate.com\/products\/sql-toolbelt-essentials\/\" class=\"btn btn--secondary btn--lg\" aria-label=\"Learn more &amp; try for free: Fast, reliable and consistent SQL Server development...\">Learn more &amp; try for free<\/a>\n                    <\/div>\n    <\/div>\n<\/section>\n\n\n<section id=\"faq\" class=\"faq-block my-5xl\">\n    <h2>FAQs: T-SQL Window Functions and Performance<\/h2>\n\n                        <h3 class=\"mt-4xl\">1. How do you improve window function performance in SQL Server?<\/h3>\n            <div class=\"faq-answer\">\n                <p>Create an index matching the PARTITION BY and ORDER BY columns in your OVER clause. For OVER(PARTITION BY CustomerID ORDER BY OrderDate), create an index on (CustomerID, OrderDate) with any SELECT columns as INCLUDE columns. This eliminates the sort operation &#8211; the most expensive part of window function execution.<\/p>\n            <\/div>\n                    <h3 class=\"mt-4xl\">2. What is NTILE in SQL and how does it work?<\/h3>\n            <div class=\"faq-answer\">\n                <p>NTILE(n) divides an ordered result set into n approximately equal groups and assigns a group number (1 through n) to each row. NTILE(4) creates quartiles, NTILE(10) deciles. Requires ORDER BY in the OVER clause. Rows are distributed as evenly as possible; if not perfectly divisible, earlier groups get one extra row. Combine with PARTITION BY for groups within categories.<\/p>\n            <\/div>\n                    <h3 class=\"mt-4xl\">3. What is the difference between ROWS and RANGE framing in window functions?<\/h3>\n            <div class=\"faq-answer\">\n                <p>ROWS defines the frame by physical row positions (ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING = exactly 3 rows). RANGE defines it by logical value ranges, including all ties. RANGE is SQL Server\u2019s default and requires an on-disk spool, which is slower. Always specify ROWS explicitly for running totals and moving averages to avoid the RANGE performance penalty.<\/p>\n            <\/div>\n                    <h3 class=\"mt-4xl\">4. Can you use COUNT DISTINCT as a window function in SQL Server?<\/h3>\n            <div class=\"faq-answer\">\n                <p>No. COUNT(DISTINCT) is not supported as a window function. Workaround: use a CTE with GROUP BY for the distinct count, then JOIN back. Or use DENSE_RANK to assign unique rankings and COUNT the results.<\/p>\n            <\/div>\n            <\/section>\n","protected":false},"excerpt":{"rendered":"<p>Master T-SQL window function performance: understand OVER clause sorting, PARTITION BY, NTILE, framing (ROWS vs RANGE), indexing strategies, and how to avoid performance bottlenecks.&hellip;<\/p>\n","protected":false},"author":110218,"featured_media":0,"comment_status":"open","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"footnotes":""},"categories":[143529,143531],"tags":[5842],"coauthors":[11292],"class_list":["post-79473","post","type-post","status-publish","format-standard","hentry","category-performance-sql-server","category-t-sql-programming-sql-server","tag-sql-monitor"],"acf":[],"_links":{"self":[{"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/posts\/79473","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/users\/110218"}],"replies":[{"embeddable":true,"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/comments?post=79473"}],"version-history":[{"count":16,"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/posts\/79473\/revisions"}],"predecessor-version":[{"id":109118,"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/posts\/79473\/revisions\/109118"}],"wp:attachment":[{"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/media?parent=79473"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/categories?post=79473"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/tags?post=79473"},{"taxonomy":"author","embeddable":true,"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/coauthors?post=79473"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}