By Fatskills Exam Guides Team — the exam nerds behind 28,500+ quizzes and 2.1M practice questions across 500+ global exams.
What it is:Aggregate window functions (SUM(), AVG(), COUNT() with OVER(PARTITION BY)) let you compute aggregations (totals, averages, counts) without collapsing rows—unlike GROUP BY, which squashes your dataset into fewer rows. Instead, they add a computed column to every row, keeping the original granularity.
SUM()
AVG()
COUNT()
OVER(PARTITION BY)
GROUP BY
Why it matters in production:- You need running totals, moving averages, or per-group metrics (e.g., "Show me daily sales and the monthly total up to that day").- You’re building dashboards where users want to see both individual records and aggregated trends (e.g., "Show me each customer’s order and their lifetime spend").- You’re debugging data quality (e.g., "Flag customers where their average order value is 3x the group average").- You’re optimizing queries—window functions often outperform self-joins or subqueries.
Real-world scenario:You’re analyzing e-commerce data. Your boss wants a report showing: - Each order (order_id, customer_id, order_date, amount) - The customer’s lifetime spend (sum of all their orders) - The average order value for their country- A flag if this order is above their personal average
Without window functions, you’d need multiple subqueries or self-joins, making the query slow and hard to maintain. With window functions, it’s one clean query.
OVER()
Production insight: If you omit PARTITION BY, the window is the entire result set—useful for grand totals, but often not what you want.
PARTITION BY
Production insight: Over-partitioning (e.g., PARTITION BY customer_id, order_id) can kill performance—only partition on what you need.
PARTITION BY customer_id, order_id
SUM() OVER()
Production insight: Use ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW for running totals (e.g., "cumulative sales").
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
AVG() OVER()
Production insight: For time-series data, use ROWS BETWEEN 6 PRECEDING AND CURRENT ROW for a 7-day moving average.
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
COUNT() OVER()
Production insight: COUNT(*) counts all rows, COUNT(column) ignores NULLs—be explicit.
COUNT(*)
COUNT(column)
ORDER BY inside OVER()
ORDER BY
Production insight: If you forget ORDER BY, the result is non-deterministic (you’ll get the same sum, but applied to random rows).
ROWS BETWEEN
Production insight: Default is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW—often not what you want for time-series data.
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
RANGE BETWEEN
ROWS
orders
sql order_id (INT), customer_id (INT), order_date (DATE), amount (DECIMAL), country (VARCHAR)
INSERT INTO orders VALUES (1, 101, '2023-01-01', 50.00, 'USA'), (2, 101, '2023-01-15', 75.00, 'USA'), (3, 102, '2023-01-05', 30.00, 'UK'), (4, 102, '2023-01-20', 40.00, 'UK'), (5, 103, '2023-01-10', 100.00, 'USA'), (6, 101, '2023-02-01', 60.00, 'USA'); ```
1
0
SELECT order_id, customer_id, order_date, amount, country, SUM(amount) OVER (PARTITION BY customer_id) AS customer_lifetime_spend FROM orders;
Expected output:
order_id | customer_id | order_date | amount | country | customer_lifetime_spend ---------+-------------+------------+--------+---------+------------------------ 1 | 101 | 2023-01-01 | 50.00 | USA | 185.00 2 | 101 | 2023-01-15 | 75.00 | USA | 185.00 6 | 101 | 2023-02-01 | 60.00 | USA | 185.00 3 | 102 | 2023-01-05 | 30.00 | UK | 70.00 4 | 102 | 2023-01-20 | 40.00 | UK | 70.00 5 | 103 | 2023-01-10 | 100.00 | USA | 100.00
SELECT order_id, customer_id, order_date, amount, country, SUM(amount) OVER (PARTITION BY customer_id) AS customer_lifetime_spend, AVG(amount) OVER (PARTITION BY country) AS country_avg_order_value FROM orders;
Expected output (new column):
country_avg_order_value ----------------------- 71.25 -- USA (50+75+60+100)/4 35.00 -- UK (30+40)/2 71.25 -- USA 35.00 -- UK 71.25 -- USA
SELECT order_id, customer_id, order_date, amount, country, SUM(amount) OVER (PARTITION BY customer_id) AS customer_lifetime_spend, AVG(amount) OVER (PARTITION BY country) AS country_avg_order_value, CASE WHEN amount > AVG(amount) OVER (PARTITION BY customer_id) THEN 1 ELSE 0 END AS is_above_personal_avg FROM orders;
is_above_personal_avg --------------------- 0 -- 50 < 61.67 (101's avg) 1 -- 75 > 61.67 0 -- 60 < 61.67 0 -- 30 < 35 (102's avg) 1 -- 40 > 35 0 -- 100 = 100 (103's avg)
SELECT order_id, customer_id, order_date, amount, country, SUM(amount) OVER (PARTITION BY customer_id ORDER BY order_date) AS running_total, SUM(amount) OVER (PARTITION BY customer_id) AS customer_lifetime_spend, AVG(amount) OVER (PARTITION BY country) AS country_avg_order_value, CASE WHEN amount > AVG(amount) OVER (PARTITION BY customer_id) THEN 1 ELSE 0 END AS is_above_personal_avg FROM orders;
running_total ------------- 50.00 -- First order for 101 125.00 -- 50 + 75 185.00 -- 125 + 60 30.00 -- First order for 102 70.00 -- 30 + 40 100.00 -- Only order for 103
EXPLAIN ANALYZE
SUM(amount) OVER (PARTITION BY customer_id) AS customer_lifetime_spend
SUM(amount) OVER (PARTITION BY customer_id) AS total
sql SUM(amount) OVER ( PARTITION BY customer_id ORDER BY order_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS running_total
NULL
RANGE
customer_id
order_id
Answer: SUM(sales) OVER (ORDER BY date)
SUM(sales) OVER (ORDER BY date)
Group averages:
Answer: AVG(salary) OVER (PARTITION BY department)
AVG(salary) OVER (PARTITION BY department)
Moving average:
Answer: AVG(price) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)
AVG(price) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)
Flagging outliers:
CASE WHEN amount > 2 * AVG(amount) OVER (PARTITION BY country) THEN 1 ELSE 0 END
Challenge:You have a table sales with columns date (DATE), product_id (INT), revenue (DECIMAL). Write a query that: 1. Shows each sale.2. Adds a column with the 3-day moving average revenue for that product.3. Flags (1 or 0) if the sale is above the product’s 3-day average.
sales
date (DATE)
product_id (INT)
revenue (DECIMAL)
Solution:
SELECT date, product_id, revenue, AVG(revenue) OVER ( PARTITION BY product_id ORDER BY date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) AS moving_avg_3day, CASE WHEN revenue > AVG(revenue) OVER ( PARTITION BY product_id ORDER BY date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) THEN 1 ELSE 0 END AS is_above_moving_avg FROM sales;
Why it works:- ROWS BETWEEN 2 PRECEDING AND CURRENT ROW = last 3 rows (including current).- PARTITION BY product_id ensures the average is per product.
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
PARTITION BY product_id
SUM(amount) OVER (PARTITION BY customer_id)
AVG(amount) OVER (PARTITION BY country)
COUNT(*) OVER (PARTITION BY customer_id)
SUM(amount) OVER (ORDER BY date)
AVG(amount) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)
CASE WHEN amount > AVG(amount) OVER (PARTITION BY group) THEN 1 ELSE 0 END
COUNT(col)
Join 4M+ learners. Unlock unlimited quizzes, wrong-answer tracking, flashcards + reminders, study guides, and 1-on-1 challenges.