- Window functions perform calculations across rows related to the current row โ without collapsing them like GROUP BY does
- ROW_NUMBER, RANK, DENSE_RANK โ essential for customer and product ranking reports
- LAG and LEAD โ the fastest way to build period-over-period comparisons in SQL
- SUM() OVER() โ running totals without self-joins or subqueries
- NTILE โ instantly segment customers into quartiles for LTV analysis
Why Finance Analysts Need Window Functions
Most SQL analysts learn SELECT, WHERE, GROUP BY, and JOIN โ and stop there. That covers 80% of queries. But the remaining 20% โ the queries that produce genuinely insightful finance reports โ almost always require window functions.
Window functions let you perform calculations across a set of rows that are related to the current row, while keeping all the individual rows in your result. The key difference from GROUP BY: GROUP BY collapses rows into one per group. Window functions add a calculated column without collapsing anything.
Every example below uses a real finance scenario you can adapt immediately.
The Syntax โ Understanding OVER()
All window functions share the same structure:
FUNCTION_NAME() OVER ( PARTITION BY column_name -- optional: reset per group ORDER BY column_name -- optional: defines row order ROWS BETWEEN ... -- optional: window frame )
The PARTITION BY clause resets the calculation for each group โ similar to GROUP BY but without collapsing. The ORDER BY clause defines the order within the partition. The ROWS BETWEEN clause defines which rows to include in the calculation window.
1. ROW_NUMBER โ Ranking Your Best Customers
ROW_NUMBER assigns a unique sequential number to each row within a partition. Classic use case: rank customers by total revenue to find your top 10.
SELECT customer_id, customer_name, segment, total_revenue, ROW_NUMBER() OVER ( ORDER BY total_revenue DESC ) AS revenue_rank, ROW_NUMBER() OVER ( PARTITION BY segment ORDER BY total_revenue DESC ) AS rank_within_segment FROM customer_revenue_summary ORDER BY revenue_rank; -- Result: every customer ranked globally AND within their segment -- customer_id | name | segment | revenue | rank | seg_rank -- C-00247 | Acme Ltd | Enterprise | 284,420 | 1 | 1 -- C-00891 | Beta Co | Mid-Market | 142,810 | 2 | 1 -- C-01204 | Gamma Inc| Enterprise | 98,340 | 3 | 2
2. LAG and LEAD โ Period-Over-Period Comparisons
LAG() accesses the value from a previous row. LEAD() accesses the value from a following row. This is the cleanest way to build month-over-month or year-over-year variance reports without self-joins.
SELECT month, revenue, LAG(revenue) OVER (ORDER BY month) AS prev_month_revenue, revenue - LAG(revenue) OVER (ORDER BY month) AS mom_variance, ROUND( (revenue - LAG(revenue) OVER (ORDER BY month)) / NULLIF(LAG(revenue) OVER (ORDER BY month), 0) * 100, 1 ) AS mom_pct_change FROM monthly_revenue ORDER BY month; -- month | revenue | prev_revenue | variance | pct_change -- 2025-01 | 38,200 | NULL | NULL | NULL -- 2025-02 | 41,800 | 38,200 | 3,600 | +9.4% -- 2025-03 | 39,100 | 41,800 | -2,700 | -6.5% -- 2025-04 | 48,200 | 39,100 | 9,100 | +23.3%
3. SUM() OVER() โ Running Totals
Running totals โ the cumulative sum up to and including the current row โ are one of the most requested finance reports. With a window function, it's a single line of DAX.
SELECT month, revenue, SUM(revenue) OVER ( ORDER BY month ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS ytd_revenue, SUM(expenses) OVER ( ORDER BY month ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS ytd_expenses, AVG(revenue) OVER ( ORDER BY month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) AS rolling_3m_avg FROM monthly_financials WHERE EXTRACT(YEAR FROM month) = 2025 ORDER BY month;
4. NTILE โ Customer Segmentation into Quartiles
NTILE(n) divides rows into n roughly equal buckets. This is the foundation of RFM (Recency, Frequency, Monetary) segmentation โ the industry-standard customer segmentation model.
WITH customer_ltv AS ( SELECT customer_id, SUM(order_value) AS lifetime_value, COUNT(order_id) AS total_orders, MAX(order_date) AS last_order_date FROM orders GROUP BY customer_id ) SELECT customer_id, lifetime_value, total_orders, NTILE(4) OVER (ORDER BY lifetime_value DESC) AS ltv_quartile, CASE WHEN NTILE(4) OVER (ORDER BY lifetime_value DESC) = 1 THEN 'Champions' WHEN NTILE(4) OVER (ORDER BY lifetime_value DESC) = 2 THEN 'Loyal' WHEN NTILE(4) OVER (ORDER BY lifetime_value DESC) = 3 THEN 'At Risk' ELSE 'Lost' END AS segment FROM customer_ltv ORDER BY lifetime_value DESC;
5. RANK vs DENSE_RANK โ When Ties Matter
ROW_NUMBER gives every row a unique number even if values are identical. RANK skips numbers after a tie. DENSE_RANK never skips numbers. For finance reports, the right choice depends on what you're ranking:
- Use ROW_NUMBER when you need exactly N rows โ e.g. top 10 products. Ties are broken arbitrarily.
- Use RANK for competition-style ranking โ if two products tie for 3rd, both get rank 3, the next gets rank 5.
- Use DENSE_RANK when you want no gaps โ if two products tie for 3rd, the next gets rank 4, not 5.
Putting It All Together โ A Complete Finance Report Query
SELECT month, revenue, expenses, revenue - expenses AS net_profit, -- Running totals SUM(revenue) OVER (ORDER BY month) AS ytd_revenue, SUM(expenses) OVER (ORDER BY month) AS ytd_expenses, -- MOM variance revenue - LAG(revenue) OVER (ORDER BY month) AS mom_variance, -- Rolling 3-month average AVG(revenue) OVER ( ORDER BY month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) AS rolling_3m_avg, -- Month rank by revenue RANK() OVER (ORDER BY revenue DESC) AS revenue_rank FROM monthly_financials WHERE EXTRACT(YEAR FROM month) = 2025 ORDER BY month;