Window Functions — Learn Analytical Functions and Build Data Marts from the Basics
BasicWindow functionsAnalytics and aggregationData martsPostgreSQL/MySQL 8.0+/BigQuery5 questions
QUESTION 1
MAX() / MIN() OVER() — Compare Rows with Group Maximums and Minimums
MAX/MINPARTITION BYBenchmark comparisonFundamentals
Background

The ability of OVER() to attach an aggregate value to every row without collapsing the rows is especially powerful with MAX() and MIN(). For example, you can place each department's highest salary beside every employee and calculate the difference from the employee's own salary in a single query.

SELECT
  name, score,
  MAX(score) OVER() AS top_score
FROM exams;
Problem

From the employee_salaries table below, retrieve each employee's name, department, and salary, along with the highest salary in that department (dept_max) and the difference from that maximum (diff_from_max).

Calculate the difference as highest salary - employee's salary.

Source table
▸ employee_salaries
emp_namedeptsalary
AliceSales300000
BobSales250000
CarolSoftware Engineering400000
DaveSoftware Engineering380000
Expected Output
emp_namedeptsalarydept_maxdiff_from_max
AliceSales3000003000000
BobSales25000030000050000
CarolSoftware Engineering4000004000000
DaveSoftware Engineering38000040000020000
Model Answer
SELECT
  emp_name,
  dept,
  salary,
  MAX(salary) OVER(PARTITION BY dept) AS dept_max,  -- Highest salary in each department
  MAX(salary) OVER(PARTITION BY dept) - salary AS diff_from_max
FROM employee_salaries                                           -- Subtract each salary from the department maximum
ORDER BY dept, diff_from_max;

/*
  Execution order:
  1. FROM employee_salaries  → Retrieve 4 rows
  2. PARTITION BY dept       → Split them into 2 department groups
  3. MAX(salary)             → Attach each group maximum to every row
  4. SELECT                  → Calculate the difference
  */
Explanation (table transitions & key points)
SELECT emp_name, dept, salary, MAX(salary) OVER(PARTITION BY dept) AS dept_max, MAX(salary) OVER(PARTITION BY dept) - salary AS diff_from_max FROM employee_salaries ORDER BY dept, diff_from_max;
LEGEND
Rows read / loaded
① FROM
FROM employee_salariesRead the entire table. It contains 4 employees in 2 departments: Sales and Software Engineering.
1 / 4
emp_namedeptsalary
AliceSales300000
BobSales250000
CarolSoftware Engineering400000
DaveSoftware Engineering380000
4 rows read
LEARNING POINTS
Relative comparisons become straightforward: Window functions excel at questions such as “How far is this employee below the highest-paid employee?” or “How far is this value from the overall average?” The query is more concise than the traditional GROUP BY and self-join approach. Performance still depends on data volume, indexes, and the execution plan, so verify it in the actual environment.
Compared with the traditional form: Without a window function, you would calculate each department's maximum in a subquery and JOIN it back to the detail rows. A window function expresses the result in one query.
⇄ Traditional form vs. window function
✖ Traditional: subquery + JOIN (written in two stages)
SELECT e.emp_name, e.dept, e.salary,
       m.dept_max,
       m.dept_max - e.salary AS diff_from_max
FROM employee_salaries e
JOIN (
  SELECT dept, MAX(salary) AS dept_max
  FROM employee_salaries
  GROUP BY dept
) m ON e.dept = m.dept
ORDER BY dept, diff_from_max;

Requires a department aggregate subquery and a JOIN

✓ Window function: expressed in one SELECT
SELECT
  emp_name, dept, salary,
  MAX(salary) OVER(PARTITION BY dept)
    AS dept_max,
  MAX(salary) OVER(PARTITION BY dept)
    - salary AS diff_from_max
FROM employee_salaries
ORDER BY dept, diff_from_max;

Concise aggregate attachment and difference calculation (actual scans and sorts depend on the plan)

QUESTION 2
COUNT() OVER() — Attach Conditional Counts and Group Totals
COUNTPARTITION BYAttach counts
Background

COUNT() is also frequently used as a window function. It can attach group-level counts and totals, such as a user's total number of orders or total order amount, to every detail row without collapsing those rows.

SELECT
  order_id, user_id,
  COUNT(*) OVER(PARTITION BY user_id) AS user_order_count
FROM orders;
Problem

From the orders table below, output the order ID, user ID, and amount, together with that user's total number of orders (order_count) and total order amount (total_amount).

Source table
▸ orders
order_iduser_idamount
1U11000
2U12000
3U21500
4U33000
5U1500
Expected Output
order_iduser_idamountorder_counttotal_amount
1U1100033500
2U1200033500
5U150033500
3U2150011500
4U3300013000
Model Answer
SELECT
  order_id,
  user_id,
  amount,
  COUNT(*) OVER(PARTITION BY user_id) AS order_count,  -- Number of orders for each user
  SUM(amount) OVER(PARTITION BY user_id) AS total_amount
FROM orders                                               -- Calculate the total amount for each user
ORDER BY user_id, order_id;

/*
  Execution order:
  1. FROM orders             → Retrieve 5 rows
  2. PARTITION BY user_id    → Split the rows by user
  3. COUNT(*) and SUM(amount) → Calculate and attach both aggregates
  4. SELECT                  → Sort by user_id and order_id
  */
Explanation (table transitions & key points)
SELECT order_id, user_id, amount, COUNT(*) OVER(PARTITION BY user_id) AS order_count, SUM(amount) OVER(PARTITION BY user_id) AS total_amount FROM orders ORDER BY user_id, order_id;
LEGEND
Rows read / loaded
① FROM
FROM ordersRead the table. It contains 5 rows: 3 orders for U1, 1 for U2, and 1 for U3. The rows have not yet been partitioned.
1 / 4
order_iduser_idamount
1U11000
2U12000
3U21500
4U33000
5U1500
5 rows read (in insertion order)
LEARNING POINTS
Flags and anomaly detection: To select only users with at least 2 orders, wrap this query in a subquery or CTE and apply WHERE order_count >= 2. This retains the detailed order history while filtering to the target users.
COUNT(column) vs. COUNT(*): As with ordinary aggregates, COUNT(column) as a window function counts only rows where that column is not NULL, whereas COUNT(*) counts every row, including rows containing NULL values.
❖ The essence of window functions: collapse rows or preserve them?
✖ GROUP BY (collapses the data to 3 rows)
user_idorder_counttotal_amount
U133500
U211500
U313000

The order_id and amount detail is lost

✓ Window function (attaches aggregates while preserving all 5 rows)
order_iduser_idamountorder_counttotal_amount
1U1100033500
2U1200033500
5U150033500
3U2150011500
4U3300013000

Aggregate values are attached to each row without losing detail

QUESTION 3
FIRST_VALUE() — Retrieve the First Value in Each Group
FIRST_VALUEORDER BYFirst action
Background

FIRST_VALUE(column) returns the value from the first row in a specified ordering. It is particularly useful when you want to attach facts such as a customer's first purchase date or first page viewed to every row in that customer's history.

SELECT
  user_id, event_date,
  FIRST_VALUE(event_name) OVER(
    PARTITION BY user_id
    ORDER BY event_date ASC
  ) AS first_event
FROM events;
Problem

For every event in the user_events table below, attach the first event performed by that user as first_event.

Source table
▸ user_events
user_idevent_dateevent_name
U104-01Registration
U104-02View
U104-05Purchase
U204-03Registration
Expected Output
user_idevent_dateevent_namefirst_event
U104-01RegistrationRegistration
U104-02ViewRegistration
U104-05PurchaseRegistration
U204-03RegistrationRegistration
Model Answer
SELECT
  user_id,
  event_date,
  event_name,
  FIRST_VALUE(event_name) OVER(
    PARTITION BY user_id
    ORDER BY event_date ASC
  ) AS first_event
FROM user_events                 -- Order each user by date and retrieve the group's first event_name
ORDER BY user_id, event_date;

/*
  Execution order:
  1. FROM user_events
  2. PARTITION BY user_id splits the rows into groups
  3. ORDER BY event_date ASC orders each group by date ascending
  4. FIRST_VALUE() attaches Registration from U1's first row (04-01) to every U1 row
  5. SELECT outputs the result
*/
Explanation (table transitions & key points)
SELECT user_id, event_date, event_name, FIRST_VALUE(event_name) OVER( PARTITION BY user_id ORDER BY event_date ASC ) AS first_event FROM user_events ORDER BY user_id, event_date;
LEGEND
Rows read / loaded
① FROM
FROM user_eventsRead the table. It contains 3 events for U1 and 1 event for U2.
1 / 4
user_idevent_dateevent_name
U104-01Registration
U104-02View
U104-05Purchase
U204-03Registration
4 rows read
LEARNING POINTS
How this differs from MIN(): MIN(event_name) returns the string that sorts first under the active collation, which is not necessarily the chronologically first event. To retrieve the first value on a timeline, use FIRST_VALUE() with an explicit ORDER BY. If timestamps can tie, add a unique tie-breaker column.
The counterpart, LAST_VALUE(): LAST_VALUE() retrieves the last value, but an ordered window's default frame generally runs from the partition start through the current row, including peers. Used as-is, it therefore does not return the final value of the entire partition. Either specify ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING, or combine ORDER BY ... DESC with FIRST_VALUE().
MIN() returns the collation minimum, not the first value in time
✖ MIN(event_name) — returns the collation minimum
event_dateevent_nameMIN result (example)
04-01RegistrationPurchase
04-02ViewPurchase
04-05PurchasePurchase

This example assumes a collation that sorts Purchase first. The result depends on collation, not date order.

✓ FIRST_VALUE() ORDER BY event_date — returns the first row by date
event_dateevent_nameFIRST_VALUE result
04-01RegistrationRegistration
04-02ViewRegistration
04-05PurchaseRegistration

ORDER BY event_date ASC correctly retrieves the chronologically first event

QUESTION 4
LEAD() — Place the Next Visit Beside the Current One
LEADPARTITION BYNext event and interval
Background

Whereas LAG() retrieves a preceding row, LEAD() retrieves the next row: data later in the specified ordering. Bringing a user's next visit date onto the current row makes it possible to calculate the revisit interval, such as how many days passed before the user returned.

Problem

From the website_visits table below, retrieve each user's visit date (visit_date) and that user's next visit date (next_visit).

If there is no next visit because the row is last in its partition, return NULL.

Source table
▸ website_visits
user_idvisit_date
U104-01
U104-05
U104-10
U204-02
Expected Output
user_idvisit_datenext_visit
U104-0104-05
U104-0504-10
U104-10NULL
U204-02NULL
Model Answer
SELECT
  user_id,
  visit_date,
  LEAD(visit_date) OVER(
    PARTITION BY user_id
    ORDER BY visit_date ASC
  ) AS next_visit
FROM website_visits            -- Order each user by date and retrieve visit_date from the next row
ORDER BY user_id, visit_date;

/*
  Execution order:
  1. FROM website_visits
  2. PARTITION BY user_id splits the rows into groups
  3. ORDER BY visit_date ASC orders each group by date ascending
  4. LEAD() retrieves visit_date from the next row in that ordering
  5. SELECT outputs the result
  */
Explanation (table transitions & key points)
SELECT user_id, visit_date, LEAD(visit_date) OVER( PARTITION BY user_id ORDER BY visit_date ASC ) AS next_visit FROM website_visits ORDER BY user_id, visit_date;
LEGEND
Rows read / loaded
① FROM
FROM website_visitsRead the table. It contains 3 visits for U1 and 1 visit for U2.
1 / 4
user_idvisit_date
U104-01
U104-05
U104-10
U204-02
4 rows read
LEARNING POINTS
Practical date-difference analysis: Subtract the current date from the next visit date to analyze revisit intervals. The expression differs by database: MySQL uses DATEDIFF(next_visit, visit_date), PostgreSQL subtracts one date from another, and BigQuery uses DATE_DIFF(next_visit, visit_date, DAY). Calculating next_visit first in a subquery or CTE makes the pattern easier to port.
NULL carries information: A row whose next_visit is NULL is the user's final row with no subsequent visit inside the observation period. To find churn candidates, wrap this result in a subquery or CTE and apply WHERE next_visit IS NULL in the outer query. Every user's last observed row qualifies, however, so confirming churn requires another condition, such as elapsed time since the observation end date.
❖ Derive days until the next visit with a date difference and LEAD
user_idvisit_datenext_visit (LEAD)days_to_next
database-specific date difference
U104-0104-05Returns after 4 days
U104-0504-10Returns after 5 days
U104-10NULLNULL (final visit in observation period)
U204-02NULLNULL (final visit in observation period)

Filtering with WHERE days_to_next IS NULL in an outer query retrieves each user's final observed row. NULL means “no next visit in the observation period”; it does not prove churn by itself.

QUESTION 5
LAG() Arguments — Specify a Default Value Instead of NULL
LAGDefault valueAvoid NULL
Background

LAG() and LEAD() return NULL by default when the target row does not exist. If that NULL is used in arithmetic such as subtraction, the result is also NULL.
To avoid this, you can provide a third argument to the function as its default value.

-- LAG(column, row offset, default value)
LAG(amount, 1, 0) OVER(ORDER BY date)
Problem

From the daily_metrics table below, retrieve the user count (users) for each date and the preceding row's user count in date order (prev_users), which is the previous day in this example.
For the first row, where no preceding row exists, return 0 instead of NULL.

Source table
▸ daily_metrics
dateusers
04-01100
04-02120
04-03150
Expected Output
dateusersprev_users
04-011000
04-02120100
04-03150120
Model Answer
SELECT
  date,
  users,
  LAG(users, 1, 0) OVER(ORDER BY date) AS prev_users  -- users from 1 row earlier, or 0 if no such row exists
FROM daily_metrics
ORDER BY date;

/*
  Execution order:
  1. FROM daily_metrics  → Retrieve the rows
  2. ORDER BY date       → Define the window order by date ascending
  3. LAG(users, 1, 0)    → Attach the preceding value (0 for the first row)
  4. SELECT              → Output the columns
  */
Explanation (table transitions & key points)
SELECT date, users, LAG(users, 1, 0) OVER(ORDER BY date) AS prev_users FROM daily_metrics ORDER BY date;
LEGEND
Rows read / loaded
① FROM
FROM daily_metricsRead the table. It contains 3 days of data. Row ordering is essential when LAG() refers to a preceding row, so the next step defines that order with ORDER BY.
1 / 4
dateusers
04-01100
04-02120
04-03150
3 rows read
LEARNING POINTS
Choosing between the default and COALESCE(): LAG's third argument is used only when the row at the requested offset does not exist. If the preceding row exists but its users value is NULL, LAG returns that NULL. By contrast, COALESCE(LAG(users) OVER(...), 0) converts either kind of NULL to 0, so the two forms are not equivalent when you must distinguish a missing value from a nonexistent row.
Looking back multiple rows: Changing the second argument to LAG(users, 7) retrieves the value 7 rows earlier. Treat those 7 rows as one week only when the data is guaranteed to contain exactly one row per day with no gaps. If dates can be missing or repeated, use a date-based join or another date-aware method.
⇄ Results without a default (NULL) vs. with a default (0)
✖ LAG(users) — no default value
dateusersprev_usersDifference (users - prev)
04-01100NULLNULL ← Cannot calculate
04-02120100+20
04-03150120+30

The first-row difference is NULL and may be excluded from later aggregates

✓ LAG(users, 1, 0) — default value 0
dateusersprev_usersDifference (users - prev)
04-011000+100 ← Can calculate
04-02120100+20
04-03150120+30

The first day is compared with 0, so a difference can be calculated for every row