Window Functions — Learn Session Analysis, Moving Averages, and Reusable Window Patterns in Practice
AdvancedWindow FunctionsPractical PatternsSession AnalysisMoving AveragesPostgreSQL/MySQL 8.0+/BigQuery5 questions
QUESTION 6
LEAD() — Look Ahead to Calculate Growth and Differences
LEADORDER BYGrowth AnalysisThe Opposite Direction from LAG
Background

LEAD(expr, offset, default) returns the value offset rows ahead of the current row. The default offset is one row. When no next row exists, such as on the last row of a partition, it returns default, or NULL when the default is omitted.

LEAD(revenue)         -- revenue from 1 row ahead (default)
LEAD(revenue, 2)      -- revenue from 2 rows ahead
LEAD(revenue, 1, 0)  -- 1 row ahead; return 0 when that row does not exist

While LAG() refers to the past—the preceding row—LEAD() refers to the future—the following row. It is commonly used for month-over-month or week-over-week comparisons and growth-rate calculations.

Problem

From the monthly_revenue table below, return each month’s revenue, the following month’s revenue as next_revenue, and the difference from the following month as diff = next_revenue − revenue. Return NULL when no following month exists.

Input Table
▸ monthly_revenue
monthrevenue
2026-01100
2026-02120
2026-0390
2026-04150
Expected Output
monthrevenuenext_revenuediff
2026-01100120+20
2026-0212090-30
2026-0390150+60
2026-04150NULLNULL

The last row, 2026-04, has no following month, so both next_revenue and diff are NULL. Any arithmetic operation involving NULL also returns NULL.

QUESTION 7
FIRST_VALUE() — Calculate the Gap from the Category Leader
FIRST_VALUEPARTITION BYCategory ComparisonThe LAST_VALUE Trap
Background

FIRST_VALUE(expr) returns the value from the first row of the window frame. Combined with PARTITION BY category ORDER BY score DESC, it consistently retrieves the highest score—the leader—in each category.

FIRST_VALUE(score) OVER(
  PARTITION BY category
  ORDER BY score DESC
) AS top_score  -- Highest score in the category

When ORDER BY is present, the default frame—normally RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW—always includes the first row, which has the highest score, so every current row receives that top value. If scores tie, rows with the same ordering value are peers in the same frame, but the highest score returned by FIRST_VALUE does not change.

Problem

From the product_scores table below, return each product’s score, the highest score in the same category as top_score, and the gap from the leader as gap = top_score − score. Sort products by descending score within each category.

Input Table
▸ product_scores
productcategoryscore
P1A85
P2A92
P3A78
P4B70
P5B88
Expected Output
productcategoryscoretop_scoregap
P2A92920
P1A85927
P3A789214
P5B88880
P4B708818

Every row in category A has top_score 92 from P2, while every row in category B has top_score 88 from P5. A row with gap=0 is the leader of its category.

QUESTION 8
Moving Average — Implement a Three-Day Sliding Window
ROWS BETWEENAVG + PRECEDINGMoving AverageSliding Window
Background

A moving average calculates the average over the latest N periods and is widely used to track trends in stock prices, sales, traffic, and other time-series data. With window functions, define the frame using ROWS BETWEEN.

AVG(amount) OVER(
  ORDER BY sale_date
  ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
)  -- Average over the latest 3 days: 2 preceding rows through the current row

Because the frame slides along the ordered rows, it automatically continues to reference the latest N records as data grows. This differs fundamentally from a cumulative average, which averages every row from the beginning through the current row.

Problem

From the daily_sales table below, calculate each day’s sales and the three-day moving average through that day as moving_avg_3. This table contains exactly one row per day with no missing dates. Return rows by ascending date; for the initial rows with fewer than three days of data, average only the rows that exist.

Input Table
▸ daily_sales
sale_dateamount
04-0130
04-0250
04-0340
04-0460
04-0580
Expected Output
sale_dateamountmoving_avg_3Frame contents
04-013030.00[30] ÷ 1
04-025040.00[30, 50] ÷ 2
04-034040.00[30, 50, 40] ÷ 3
04-046050.00[50, 40, 60] ÷ 3 ← 04-01 leaves the frame
04-058060.00[40, 60, 80] ÷ 3 ← slides again
QUESTION 9
WINDOW Clause — Write DRY SQL with a Shared Window Definition
WINDOW ClauseDRY PrincipleMaintainabilityPostgreSQL/MySQL 8+/BigQuery
Background

When several window functions repeat the same PARTITION BY ... ORDER BY ..., a WINDOW clause lets you define a named window once and reuse it, following the DRY: Don't Repeat Yourself principle.

SELECT
  ROW_NUMBER() OVER(w) AS rank,   -- Refer to the same window w
  SUM(sales)   OVER(w) AS running,
  LAG(sales)   OVER(w) AS prev
FROM t
WINDOW w AS (PARTITION BY emp_id ORDER BY month); -- Define it in one place

Without a WINDOW clause, the same query would copy and paste PARTITION BY emp_id ORDER BY month three times. A named window is more maintainable because changing the definition requires editing only one place.

Problem

From the employee_sales table below, attach the following three metrics to every employee-month. Use a WINDOW clause to define PARTITION BY emp_id, dept ORDER BY month in one place.

  1. rank: which numbered month this is for the employee, using ROW_NUMBER
  2. running: the employee’s cumulative sales through that point, using SUM
  3. prev_sales: the employee’s sales in the preceding month, using LAG; NULL for the first month
Input Table
▸ employee_sales
emp_iddeptmonthsales
E1Sales01100
E1Sales02120
E2Sales0180
E2Sales0290
E3Tech01150
Expected Output
emp_iddeptmonthsalesrankrunningprev_sales
E1Sales011001100NULL
E1Sales021202220100
E2Sales0180180NULL
E2Sales0290217080
E3Tech011501150NULL
QUESTION 10
Advanced Review — Implement Session Analysis with CTE + LAG + SUM
CTELAG + CASE WHENSession AnalysisCommon Production Pattern
Background

Session analysis is a fundamental product-analytics technique for identifying each session, or sequence of continuous actions, in a user’s event log. Use LAG + CASE WHEN to detect the rule “start a new session when more than 30 minutes have elapsed since the preceding event,” then cumulatively SUM the flags to assign session numbers. A gap of exactly 30 minutes remains in the same session.

WITH gap_flagged AS (
  SELECT ...,
    CASE WHEN LAG(event_time) OVER(...) IS NULL
            OR (event_time - LAG(event_time) OVER(...)) > 30
         THEN 1 ELSE 0 END AS is_new_session
  FROM user_events
)
SELECT ...,
  SUM(is_new_session) OVER(PARTITION BY user_id ORDER BY event_time) AS session_id
FROM gap_flagged;

event_time is represented as an integer number of minutes since midnight: for example, 600 means 10:00 and 660 means 11:00. In a production database, calculate the time difference with a function or expression such as DATEDIFF or EXTRACT(EPOCH FROM ...), according to the database dialect.

Problem

From the user_events table below, assign a session number as session_id for each user under this rule: start a new session when the gap from the preceding event exceeds 30 minutes, meaning the difference in event_time is greater than 30. Each user’s first event starts session 1.

Input Table (event_time is minutes since midnight: 600=10:00, 605=10:05, and so on)
▸ user_events
user_idevent_timeevent_type
U1600login
U1605click
U1645click
U1650logout
U2660login
U2670click

U1: 600→605 (gap 5 min) →645 (gap 40 min → new session) →650 (gap 5 min) / U2: 660→670 (gap 10 min)

Expected Output
user_idevent_timeevent_typesession_id
U1600login1
U1605click1
U1645click2 ← new session after a 40-minute gap
U1650logout2
U2660login1
U2670click1