By Fatskills Exam Guides Team — the exam nerds behind 28,500+ quizzes and 2.1M practice questions across 500+ global exams.
A hyper-practical, zero-fluff guide for real projects and certifications (PL-300, Google Data Analytics, etc.)
You’re analyzing time-series data—sales, stock prices, server metrics, or user activity—and you need to smooth out noise or track cumulative trends. Rolling (moving) averages and running totals are your go-to tools.
Superpower: Window functions let you compute aggregates without collapsing rows, keeping your original dataset intact while adding derived columns.
Window Function A function that operates on a "window" of rows relative to the current row, without grouping (unlike GROUP BY). Production insight: If you see OVER(), you’re using a window function. These are 10–100x faster than self-joins for time-series analysis.
GROUP BY
OVER()
OVER() Clause Defines the window of rows to consider. Syntax: OVER([PARTITION BY ...] [ORDER BY ...] [ROWS BETWEEN ...]). Production insight: Forgetting ORDER BY in time-series windows is a top cause of silent bugs—your averages will be wrong but look plausible.
OVER([PARTITION BY ...] [ORDER BY ...] [ROWS BETWEEN ...])
ORDER BY
ROWS BETWEEN Specifies the frame of rows around the current row (e.g., ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING). Production insight: Default frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW—this trips up 80% of beginners.
ROWS BETWEEN
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
UNBOUNDED PRECEDING / UNBOUNDED FOLLOWING Includes all rows from the start/end of the partition. Production insight: Use UNBOUNDED PRECEDING for running totals; UNBOUNDED FOLLOWING is rare but useful for "reverse" running totals.
UNBOUNDED PRECEDING
UNBOUNDED FOLLOWING
PARTITION BY Splits the window into groups (like GROUP BY, but doesn’t collapse rows). Production insight: Over-partitioning (e.g., PARTITION BY user_id, date, hour) can kill performance—only partition by what you need.
PARTITION BY
PARTITION BY user_id, date, hour
ORDER BY in OVER() Determines the sequence of rows in the window. Production insight: If your data isn’t pre-sorted, ORDER BY in the window function doesn’t guarantee output order—add an outer ORDER BY if needed.
AVG() OVER() Calculates a rolling average. Production insight: For daily data, AVG(value) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) gives a 7-day moving average.
AVG() OVER()
AVG(value) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)
SUM() OVER() Calculates a running total. Production insight: SUM(sales) OVER (ORDER BY date) gives a cumulative sum—critical for "year-to-date" metrics.
SUM() OVER()
SUM(sales) OVER (ORDER BY date)
FIRST_VALUE() / LAST_VALUE() Grabs the first/last value in the window. Production insight: LAST_VALUE() is tricky—it defaults to RANGE BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING, which often returns the current row. Use ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING to fix it.
FIRST_VALUE()
LAST_VALUE()
RANGE BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
LAG() / LEAD() Accesses previous/following rows (e.g., LAG(sales, 1) = yesterday’s sales). Production insight: LAG() is faster than self-joins for comparing consecutive rows.
LAG()
LEAD()
LAG(sales, 1)
CREATE TABLE daily_sales ( date DATE, product_id INT, revenue DECIMAL(10,2) ); -- Sample data INSERT INTO daily_sales VALUES ('2023-01-01', 1, 100), ('2023-01-02', 1, 150), ('2023-01-03', 1, 200), ('2023-01-01', 2, 50), ('2023-01-02', 2, 75), ('2023-01-03', 2, 100);
Goal: Add a column showing the average revenue over the last 3 days for each product.
SELECT date, product_id, revenue, AVG(revenue) OVER ( PARTITION BY product_id ORDER BY date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) AS rolling_avg_3d FROM daily_sales ORDER BY product_id, date;
Expected Output:
date | product_id | revenue | rolling_avg_3d -----------+------------+---------+---------------- 2023-01-01 | 1 | 100.00 | 100.00 -- Only 1 row in window 2023-01-02 | 1 | 150.00 | 125.00 -- (100 + 150) / 2 2023-01-03 | 1 | 200.00 | 150.00 -- (100 + 150 + 200) / 3 2023-01-01 | 2 | 50.00 | 50.00 2023-01-02 | 2 | 75.00 | 62.50 2023-01-03 | 2 | 100.00 | 75.00
Why it works:- PARTITION BY product_id resets the window for each product.- ROWS BETWEEN 2 PRECEDING AND CURRENT ROW includes the current row + 2 prior rows.- AVG() calculates the mean over this window.
PARTITION BY product_id
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
AVG()
Goal: Add a column showing the cumulative revenue for each product.
SELECT date, product_id, revenue, SUM(revenue) OVER ( PARTITION BY product_id ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS running_total FROM daily_sales ORDER BY product_id, date;
date | product_id | revenue | running_total -----------+------------+---------+-------------- 2023-01-01 | 1 | 100.00 | 100.00 2023-01-02 | 1 | 150.00 | 250.00 -- 100 + 150 2023-01-03 | 1 | 200.00 | 450.00 -- 100 + 150 + 200 2023-01-01 | 2 | 50.00 | 50.00 2023-01-02 | 2 | 75.00 | 125.00 2023-01-03 | 2 | 100.00 | 225.00
Why it works:- ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW includes all rows from the start of the partition to the current row.- SUM() adds up the values in this window.
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
SUM()
LAG
Goal: Add a column showing the revenue difference vs. the prior day.
SELECT date, product_id, revenue, revenue - LAG(revenue, 1) OVER ( PARTITION BY product_id ORDER BY date ) AS day_over_day_change FROM daily_sales ORDER BY product_id, date;
date | product_id | revenue | day_over_day_change -----------+------------+---------+--------------------- 2023-01-01 | 1 | 100.00 | NULL -- No prior day 2023-01-02 | 1 | 150.00 | 50.00 -- 150 - 100 2023-01-03 | 1 | 200.00 | 50.00 -- 200 - 150 2023-01-01 | 2 | 50.00 | NULL 2023-01-02 | 2 | 75.00 | 25.00 2023-01-03 | 2 | 100.00 | 25.00
Why it works:- LAG(revenue, 1) fetches the revenue from the previous row in the window.- The first row in each partition has no prior row, so it returns NULL.
LAG(revenue, 1)
NULL
sql CREATE INDEX idx_daily_sales_product_date ON daily_sales(product_id, date);
RANGE BETWEEN
WINDOW
sql SELECT date, revenue, AVG(revenue) OVER w AS rolling_avg, SUM(revenue) OVER w AS running_total FROM daily_sales WINDOW w AS (PARTITION BY product_id ORDER BY date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW);
-- 7-day MA for smoothing weekly seasonality
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
RANGE
ROWS
product_id
product_id + date
LAG/LEAD
LAG(revenue, 1, 0)
0
"Which frame gives a 7-day moving average?"
ROWS BETWEEN 6 PRECEDING AND 1 FOLLOWING
Running totals:
"How do you calculate a year-to-date total?"
SUM(revenue) OVER (PARTITION BY year ORDER BY date)
SUM(revenue) OVER (PARTITION BY date ORDER BY year)
LAG vs LEAD:
LEAD
"How do you compare today’s sales to yesterday’s?"
revenue - LAG(revenue, 1) OVER (ORDER BY date)
revenue - LEAD(revenue, 1) OVER (ORDER BY date)
Edge cases:
AVG(revenue) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
RANGE = logical values (e.g., RANGE BETWEEN 1 PRECEDING AND 1 FOLLOWING includes all rows with values ±1 from current row). Avoid RANGE for time-series data.
RANGE BETWEEN 1 PRECEDING AND 1 FOLLOWING
Default window frames:
SUM() OVER (ORDER BY date)
AVG() OVER (ORDER BY date)
LAST_VALUE() OVER (ORDER BY date)
Challenge:You have a table server_metrics with columns timestamp, server_id, and cpu_usage. Write a query to: 1. Calculate a 5-minute rolling average of CPU usage per server.2. Flag any row where the current CPU usage is > 2x the 5-minute average.
server_metrics
timestamp
server_id
cpu_usage
Solution:
SELECT timestamp, server_id, cpu_usage, AVG(cpu_usage) OVER ( PARTITION BY server_id ORDER BY timestamp ROWS BETWEEN 4 PRECEDING AND CURRENT ROW ) AS rolling_avg_5min, CASE WHEN cpu_usage > 2 * AVG(cpu_usage) OVER ( PARTITION BY server_id ORDER BY timestamp ROWS BETWEEN 4 PRECEDING AND CURRENT ROW ) THEN 'ALERT' ELSE 'OK' END AS status FROM server_metrics ORDER BY server_id, timestamp;
Why it works:- ROWS BETWEEN 4 PRECEDING AND CURRENT ROW = 5-minute window (assuming 1 row per minute).- The CASE statement compares the current cpu_usage to 2x the rolling average.
ROWS BETWEEN 4 PRECEDING AND CURRENT ROW
CASE
AVG(value) OVER (PARTITION BY group ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)
SUM(value) OVER (PARTITION BY group ORDER BY date)
value - LAG(value, 1) OVER (PARTITION BY group ORDER BY date)
FIRST_VALUE(value) OVER (PARTITION BY group ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)
Join 4M+ learners. Unlock unlimited quizzes, wrong-answer tracking, flashcards + reminders, study guides, and 1-on-1 challenges.