Master SQL window functions for advanced analytics, rankings, running totals, and moving averages without GROUP BY.
Imagine you're analyzing sales data and need to:
You could write complex self-joins and subqueries... or you could use window functions!
Window functions perform calculations across a set of rows related to the current row, without collapsing the result set like GROUP BY does. They're essential for analytics, reporting, and data science queries.
In this lesson, you'll master these powerful SQL features that transform complex analytical questions into elegant, performant queries.
Window functions operate on a "window" of rows related to the current row. Unlike aggregate functions with GROUP BY, window functions don't collapse rows—they add calculated columns while preserving all original rows.
Key Characteristics:
Syntax:
1function_name() OVER (
2 [PARTITION BY partition_expression]
3 [ORDER BY sort_expression]
4 [frame_clause]
5)Real-World Analogy:
Think of a window function like looking through a window at your neighborhood:
Example - Difference Between GROUP BY and Window Functions:
1-- With GROUP BY (collapses rows):
2SELECT
3 department,
4 COUNT(*) as employee_count,
5 AVG(salary) as avg_salary
6FROM employees
7GROUP BY department;
8
9-- Result: One row per department (3 rows if 3 departments)1-- With Window Function (keeps all rows):
2SELECT
3 employee_name,
4 department,
5 salary,
6 AVG(salary) OVER (PARTITION BY department) as dept_avg_salary,
7 salary - AVG(salary) OVER (PARTITION BY department) as diff_from_avg
8FROM employees;
9
10-- Result: One row per employee (100 rows if 100 employees)
11-- Each row shows employee's salary compared to their department averageWhen to Use Window Functions:
✅ Rankings and top-N queries
✅ Running totals and cumulative sums
✅ Moving averages and sliding windows
✅ Comparing each row to group statistics
✅ Finding previous/next row values
✅ Analytical reporting and dashboards
When NOT to Use:
❌ Simple aggregations where GROUP BY is clearer
❌ When you actually want collapsed results
❌ With databases that don't support window functions (old MySQL versions)
Ranking functions assign a rank to each row within a partition. They're perfect for "top N" queries and leaderboards.
1. ROW_NUMBER()
1-- Rank salespeople by revenue (unique ranks):
2SELECT
3 salesperson,
4 region,
5 revenue,
6 ROW_NUMBER() OVER (ORDER BY revenue DESC) as overall_rank,
7 ROW_NUMBER() OVER (PARTITION BY region ORDER BY revenue DESC) as region_rank
8FROM sales;
9
10/* Result:
11salesperson | region | revenue | overall_rank | region_rank
12------------|--------|---------|--------------|-------------
13Alice | East | 150000 | 1 | 1
14Bob | West | 145000 | 2 | 1
15Carol | East | 140000 | 3 | 2
16Dave | West | 140000 | 4 | 2 (different from Carol's)
17*/2. RANK()
1-- Rank products by price (with ties and gaps):
2SELECT
3 product_name,
4 price,
5 RANK() OVER (ORDER BY price DESC) as price_rank
6FROM products;
7
8/* Result:
9product_name | price | price_rank
10-------------|--------|------------
11Laptop Pro | 999.99 | 1
12Desktop Max | 999.99 | 1 (tied for 1st)
13Tablet Plus | 699.99 | 3 (rank 2 skipped!)
14Monitor HD | 499.99 | 4
15*/3. DENSE_RANK()
1-- Dense ranking (no gaps):
2SELECT
3 product_name,
4 price,
5 DENSE_RANK() OVER (ORDER BY price DESC) as price_rank
6FROM products;
7
8/* Result:
9product_name | price | price_rank
10-------------|--------|------------
11Laptop Pro | 999.99 | 1
12Desktop Max | 999.99 | 1 (tied for 1st)
13Tablet Plus | 699.99 | 2 (no gap!)
14Monitor HD | 499.99 | 3
15*/Comparison Table:
| Values | ROW_NUMBER | RANK | DENSE_RANK |
|--------|------------|------|------------|
| 100 | 1 | 1 | 1 |
| 100 | 2 | 1 | 1 |
| 95 | 3 | 3 | 2 |
| 95 | 4 | 3 | 2 |
| 90 | 5 | 5 | 3 |
Practical Example - Top 3 Products Per Category:
1WITH ranked_products AS (
2 SELECT
3 category,
4 product_name,
5 sales,
6 RANK() OVER (
7 PARTITION BY category
8 ORDER BY sales DESC
9 ) as category_rank
10 FROM products
11)
12SELECT
13 category,
14 product_name,
15 sales,
16 category_rank
17FROM ranked_products
18WHERE category_rank <= 3
19ORDER BY category, category_rank;
20
21-- Returns top 3 products in each category
22-- Handles ties appropriately (if 2 products tied for 3rd, both included)NTILE() - Dividing Data into Buckets:
1-- Divide customers into 4 quartiles by revenue:
2SELECT
3 customer_name,
4 total_revenue,
5 NTILE(4) OVER (ORDER BY total_revenue DESC) as revenue_quartile
6FROM customers;
7
8/* Result:
9customer_name | total_revenue | revenue_quartile
10--------------|---------------|------------------
11MegaCorp | 500000 | 1 (top 25%)
12BigCo | 450000 | 1
13MediumInc | 300000 | 2
14SmallBiz | 150000 | 3
15TinyShop | 50000 | 4 (bottom 25%)
16*/You can use familiar aggregate functions (SUM, AVG, COUNT, MIN, MAX) as window functions to create running calculations without GROUP BY.
Running Totals (Cumulative Sum):
1-- Calculate running total of daily sales:
2SELECT
3 sale_date,
4 daily_revenue,
5 SUM(daily_revenue) OVER (
6 ORDER BY sale_date
7 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
8 ) as running_total
9FROM daily_sales
10ORDER BY sale_date;
11
12/* Result:
13sale_date | daily_revenue | running_total
14-----------|---------------|---------------
152024-01-01 | 1000 | 1000
162024-01-02 | 1500 | 2500 (1000 + 1500)
172024-01-03 | 1200 | 3700 (2500 + 1200)
182024-01-04 | 1800 | 5500 (3700 + 1800)
19*/
20
21-- Shorthand (default frame for ORDER BY is UNBOUNDED PRECEDING to CURRENT ROW):
22SUM(daily_revenue) OVER (ORDER BY sale_date) as running_totalMoving Averages (Rolling Average):
1-- 7-day moving average of daily sales:
2SELECT
3 sale_date,
4 daily_revenue,
5 AVG(daily_revenue) OVER (
6 ORDER BY sale_date
7 ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
8 ) as moving_avg_7_days
9FROM daily_sales
10ORDER BY sale_date;
11
12/* Explanation:
13- Day 1: Avg of 1 day (just day 1)
14- Day 2: Avg of 2 days (days 1-2)
15- Day 7: Avg of 7 days (days 1-7)
16- Day 8: Avg of 7 days (days 2-8) ← Window slides!
17- Day 10: Avg of 7 days (days 4-10)
18*/Comparing to Previous Row (LAG) and Next Row (LEAD):
1-- Compare each month's sales to previous month:
2SELECT
3 month,
4 monthly_revenue,
5 LAG(monthly_revenue) OVER (ORDER BY month) as prev_month_revenue,
6 monthly_revenue - LAG(monthly_revenue) OVER (ORDER BY month) as month_over_month_change,
7 ROUND(
8 100.0 * (monthly_revenue - LAG(monthly_revenue) OVER (ORDER BY month))
9 / LAG(monthly_revenue) OVER (ORDER BY month),
10 2
11 ) as percent_change
12FROM monthly_sales
13ORDER BY month;
14
15/* Result:
16month | monthly_revenue | prev_month_revenue | change | percent_change
17--------|-----------------|-------------------|--------|----------------
182024-01 | 100000 | NULL | NULL | NULL
192024-02 | 120000 | 100000 | 20000 | 20.00
202024-03 | 115000 | 120000 | -5000 | -4.17
21*/FIRST_VALUE() and LAST_VALUE():
1-- Compare each employee's salary to highest and lowest in their department:
2SELECT
3 employee_name,
4 department,
5 salary,
6 FIRST_VALUE(salary) OVER (
7 PARTITION BY department
8 ORDER BY salary DESC
9 ) as highest_dept_salary,
10 salary - FIRST_VALUE(salary) OVER (
11 PARTITION BY department
12 ORDER BY salary DESC
13 ) as gap_from_highest
14FROM employees;Window Frames Explained:
The frame clause defines which rows to include in the calculation:
1ROWS BETWEEN frame_start AND frame_end
2
3Frame boundaries:
4- UNBOUNDED PRECEDING: Start of partition
5- N PRECEDING: N rows before current
6- CURRENT ROW: The current row
7- N FOLLOWING: N rows after current
8- UNBOUNDED FOLLOWING: End of partitionCommon Frame Patterns:
1-- All rows from start to current (running total):
2ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
3
4-- Current row only:
5ROWS BETWEEN CURRENT ROW AND CURRENT ROW
6
7-- Last 7 rows including current (7-day moving average):
8ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
9
10-- Centered window (3 rows before, current, 3 after):
11ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING
12
13-- All rows in partition (overall average):
14ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWINGPARTITION BY divides rows into groups (partitions) and applies the window function separately to each group. Think of it as "GROUP BY for window functions" but without collapsing rows.
Syntax:
1window_function() OVER (
2 PARTITION BY column1, column2, ...
3 ORDER BY sort_column
4)Example: Sales Analysis by Region:
1SELECT
2 salesperson,
3 region,
4 revenue,
5 -- Rank within each region separately:
6 RANK() OVER (
7 PARTITION BY region
8 ORDER BY revenue DESC
9 ) as region_rank,
10 -- Calculate percentage of region's total revenue:
11 ROUND(
12 100.0 * revenue / SUM(revenue) OVER (PARTITION BY region),
13 2
14 ) as pct_of_region_revenue,
15 -- Compare to region average:
16 revenue - AVG(revenue) OVER (PARTITION BY region) as diff_from_region_avg
17FROM sales
18ORDER BY region, revenue DESC;
19
20/* Result:
21salesperson | region | revenue | region_rank | pct_of_region | diff_from_avg
22------------|--------|---------|-------------|---------------|---------------
23Alice | East | 150000 | 1 | 51.72 | 55000
24Carol | East | 140000 | 2 | 48.28 | 45000
25(East average: 145000, East total: 290000)
26
27Bob | West | 145000 | 1 | 50.88 | 2500
28Dave | West | 140000 | 2 | 49.12 | -2500
29(West average: 142500, West total: 285000)
30*/Multiple Partitions Example:
1-- Rank products by sales within each category AND subcategory:
2SELECT
3 category,
4 subcategory,
5 product_name,
6 sales,
7 -- Rank within subcategory:
8 DENSE_RANK() OVER (
9 PARTITION BY category, subcategory
10 ORDER BY sales DESC
11 ) as subcategory_rank,
12 -- Rank within category (across all subcategories):
13 DENSE_RANK() OVER (
14 PARTITION BY category
15 ORDER BY sales DESC
16 ) as category_rank,
17 -- Overall rank (no partition):
18 DENSE_RANK() OVER (
19 ORDER BY sales DESC
20 ) as overall_rank
21FROM products
22ORDER BY category, subcategory, sales DESC;When to Use PARTITION BY:
✅ Calculate statistics per group while keeping detail rows
✅ Rank items within categories
✅ Running totals per group (reset at each partition)
✅ Compare individual values to group averages
✅ Find top N within each group
Real-World Use Cases:
1. E-Commerce: Product Recommendations
1-- Find products frequently bought together (market basket analysis):
2WITH product_pairs AS (
3 SELECT
4 o1.product_id as product_a,
5 o2.product_id as product_b,
6 COUNT(*) as times_bought_together
7 FROM order_items o1
8 JOIN order_items o2
9 ON o1.order_id = o2.order_id
10 AND o1.product_id < o2.product_id
11 GROUP BY o1.product_id, o2.product_id
12)
13SELECT
14 product_a,
15 product_b,
16 times_bought_together,
17 RANK() OVER (
18 PARTITION BY product_a
19 ORDER BY times_bought_together DESC
20 ) as recommendation_rank
21FROM product_pairs
22WHERE recommendation_rank <= 5; -- Top 5 recommendations per product2. SaaS Analytics: Customer Churn Detection
1-- Identify customers with declining activity:
2SELECT
3 customer_id,
4 month,
5 monthly_active_days,
6 AVG(monthly_active_days) OVER (
7 PARTITION BY customer_id
8 ORDER BY month
9 ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
10 ) as avg_last_3_months,
11 LAG(monthly_active_days, 1) OVER (
12 PARTITION BY customer_id
13 ORDER BY month
14 ) as prev_month,
15 CASE
16 WHEN monthly_active_days < LAG(monthly_active_days, 1) OVER (
17 PARTITION BY customer_id ORDER BY month
18 ) * 0.5
19 THEN 'At Risk'
20 ELSE 'Healthy'
21 END as churn_risk
22FROM customer_activity
23WHERE month >= CURRENT_DATE - INTERVAL '6 months';3. Financial: Year-over-Year Growth
1-- Calculate YoY revenue growth:
2SELECT
3 year,
4 quarter,
5 revenue,
6 LAG(revenue, 4) OVER (ORDER BY year, quarter) as revenue_last_year,
7 ROUND(
8 100.0 * (revenue - LAG(revenue, 4) OVER (ORDER BY year, quarter))
9 / LAG(revenue, 4) OVER (ORDER BY year, quarter),
10 2
11 ) as yoy_growth_pct
12FROM quarterly_revenue
13ORDER BY year, quarter;Performance Tips:
1-- Good index for PARTITION BY region ORDER BY revenue DESC:
2CREATE INDEX idx_sales_region_revenue ON sales(region, revenue DESC);1-- Faster (7-day window):
2ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
3
4-- Slower (entire partition):
5ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING1-- Good: Calculate once in CTE
2WITH ranked_sales AS (
3 SELECT
4 *,
5 RANK() OVER (PARTITION BY region ORDER BY revenue DESC) as rnk
6 FROM sales
7)
8SELECT * FROM ranked_sales WHERE rnk <= 5;
9
10-- Bad: Redundant calculations
11SELECT * FROM (
12 SELECT *, RANK() OVER (...) as rnk FROM sales
13) WHERE rnk <= 5;1-- WRONG (causes error):
2SELECT
3 product_name,
4 RANK() OVER (ORDER BY sales DESC) as sales_rank
5FROM products
6WHERE sales_rank <= 10; -- ❌ Can't reference window function in WHERE
7
8-- CORRECT (use subquery or CTE):
9WITH ranked AS (
10 SELECT
11 product_name,
12 RANK() OVER (ORDER BY sales DESC) as sales_rank
13 FROM products
14)
15SELECT * FROM ranked WHERE sales_rank <= 10;Common Pitfalls:
❌ Forgetting ORDER BY (results are unpredictable)
❌ Using wrong frame (ROWS vs RANGE)
❌ Not understanding NULL handling (NULLs sort first or last)
❌ Overusing window functions (sometimes GROUP BY is clearer)
❌ Not testing with edge cases (empty partitions, single-row partitions)
Build comprehensive sales analytics using multiple window functions
1-- Sales Dashboard: Complete Analytics with Window Functions
2
3-- Sample Data
4CREATE TEMP TABLE sales_data (
5 sale_id INT,
6 sale_date DATE,
7 salesperson VARCHAR(50),
8 region VARCHAR(50),
9 product_category VARCHAR(50),
10 revenue DECIMAL(10,2)
11);
12
13INSERT INTO sales_data VALUES
14 (1, '2024-01-01', 'Alice', 'East', 'Electronics', 15000),
15 (2, '2024-01-01', 'Bob', 'West', 'Electronics', 12000),
16 (3, '2024-01-02', 'Alice', 'East', 'Furniture', 8000),
17 (4, '2024-01-02', 'Carol', 'East', 'Electronics', 18000),
18 (5, '2024-01-03', 'Bob', 'West', 'Furniture', 10000),
19 (6, '2024-01-03', 'Dave', 'West', 'Electronics', 16000),
20 (7, '2024-01-04', 'Alice', 'East', 'Electronics', 14000),
21 (8, '2024-01-04', 'Carol', 'East', 'Furniture', 9000);
22
23-- Query 1: Daily Sales with Running Totals and Moving Averages
24SELECT
25 sale_date,
26 SUM(revenue) as daily_revenue,
27
28 -- Running total:
29 SUM(SUM(revenue)) OVER (
30 ORDER BY sale_date
31 ) as cumulative_revenue,
32
33 -- 3-day moving average:
34 AVG(SUM(revenue)) OVER (
35 ORDER BY sale_date
36 ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
37 ) as moving_avg_3_days,
38
39 -- Day-over-day change:
40 SUM(revenue) - LAG(SUM(revenue)) OVER (ORDER BY sale_date) as day_over_day_change
41
42FROM sales_data
43GROUP BY sale_date
44ORDER BY sale_date;
45
46
47-- Query 2: Salesperson Performance Rankings
48SELECT
49 salesperson,
50 region,
51 SUM(revenue) as total_revenue,
52
53 -- Overall rank:
54 RANK() OVER (ORDER BY SUM(revenue) DESC) as overall_rank,
55
56 -- Region rank:
57 RANK() OVER (
58 PARTITION BY region
59 ORDER BY SUM(revenue) DESC
60 ) as region_rank,
61
62 -- Percentage of total revenue:
63 ROUND(
64 100.0 * SUM(revenue) / SUM(SUM(revenue)) OVER (),
65 2
66 ) as pct_of_total,
67
68 -- Percentage of region revenue:
69 ROUND(
70 100.0 * SUM(revenue) / SUM(SUM(revenue)) OVER (PARTITION BY region),
71 2
72 ) as pct_of_region
73
74FROM sales_data
75GROUP BY salesperson, region
76ORDER BY overall_rank;
77
78
79-- Query 3: Product Category Analysis
80SELECT
81 product_category,
82 sale_date,
83 SUM(revenue) as daily_category_revenue,
84
85 -- Category ranking by day:
86 DENSE_RANK() OVER (
87 PARTITION BY sale_date
88 ORDER BY SUM(revenue) DESC
89 ) as daily_rank,
90
91 -- Category's running total:
92 SUM(SUM(revenue)) OVER (
93 PARTITION BY product_category
94 ORDER BY sale_date
95 ) as category_cumulative_revenue,
96
97 -- Best day for this category so far:
98 MAX(SUM(revenue)) OVER (
99 PARTITION BY product_category
100 ORDER BY sale_date
101 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
102 ) as category_best_day_so_far
103
104FROM sales_data
105GROUP BY product_category, sale_date
106ORDER BY product_category, sale_date;
107
108
109-- Query 4: Top Performers Report
110WITH salesperson_stats AS (
111 SELECT
112 salesperson,
113 region,
114 COUNT(*) as num_sales,
115 SUM(revenue) as total_revenue,
116 AVG(revenue) as avg_sale_size,
117 MAX(revenue) as largest_sale
118 FROM sales_data
119 GROUP BY salesperson, region
120),
121ranked_salespeople AS (
122 SELECT
123 *,
124 RANK() OVER (ORDER BY total_revenue DESC) as revenue_rank,
125 RANK() OVER (ORDER BY avg_sale_size DESC) as avg_sale_rank,
126 NTILE(3) OVER (ORDER BY total_revenue DESC) as performance_tier
127 FROM salesperson_stats
128)
129SELECT
130 salesperson,
131 region,
132 num_sales,
133 total_revenue,
134 avg_sale_size,
135 revenue_rank,
136 CASE performance_tier
137 WHEN 1 THEN 'Top Tier'
138 WHEN 2 THEN 'Mid Tier'
139 WHEN 3 THEN 'Developing'
140 END as performance_category
141FROM ranked_salespeople
142ORDER BY revenue_rank;This comprehensive example demonstrates multiple window function patterns in a real sales dashboard scenario.
Query 1: Daily Sales Trends
Query 2: Salesperson Performance
Query 3: Category Analysis
Query 4: Performance Tiers
Key Takeaways:
Try These Modifications:
Excellent work mastering window functions! You now have powerful tools for analytical queries that would be extremely complex with traditional SQL.
In the next lesson, we'll explore Common Table Expressions (CTEs) and Recursive Queries. You'll learn how to write cleaner, more maintainable queries using WITH clauses, and tackle hierarchical data structures like organizational charts and category trees using recursive CTEs.
Window functions + CTEs = unstoppable SQL developer! 🚀
Your progress is saved in this browser, on this device only.