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;
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.
| emp_name | dept | salary |
|---|---|---|
| Alice | Sales | 300000 |
| Bob | Sales | 250000 |
| Carol | Software Engineering | 400000 |
| Dave | Software Engineering | 380000 |
| emp_name | dept | salary | dept_max | diff_from_max |
|---|---|---|---|---|
| Alice | Sales | 300000 | 300000 | 0 |
| Bob | Sales | 250000 | 300000 | 50000 |
| Carol | Software Engineering | 400000 | 400000 | 0 |
| Dave | Software Engineering | 380000 | 400000 | 20000 |
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 */
LEGEND
① FROM
FROM employee_salariesRead the entire table. It contains 4 employees in 2 departments: Sales and Software Engineering.| emp_name | dept | salary |
|---|---|---|
| Alice | Sales | 300000 |
| Bob | Sales | 250000 |
| Carol | Software Engineering | 400000 |
| Dave | Software Engineering | 380000 |
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
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)
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;
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).
| order_id | user_id | amount |
|---|---|---|
| 1 | U1 | 1000 |
| 2 | U1 | 2000 |
| 3 | U2 | 1500 |
| 4 | U3 | 3000 |
| 5 | U1 | 500 |
| order_id | user_id | amount | order_count | total_amount |
|---|---|---|---|---|
| 1 | U1 | 1000 | 3 | 3500 |
| 2 | U1 | 2000 | 3 | 3500 |
| 5 | U1 | 500 | 3 | 3500 |
| 3 | U2 | 1500 | 1 | 1500 |
| 4 | U3 | 3000 | 1 | 3000 |
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 */
LEGEND
① 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.| order_id | user_id | amount |
|---|---|---|
| 1 | U1 | 1000 |
| 2 | U1 | 2000 |
| 3 | U2 | 1500 |
| 4 | U3 | 3000 |
| 5 | U1 | 500 |
WHERE order_count >= 2. This retains the detailed order history while filtering to the target users.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.| user_id | order_count | total_amount |
|---|---|---|
| U1 | 3 | 3500 |
| U2 | 1 | 1500 |
| U3 | 1 | 3000 |
The order_id and amount detail is lost
| order_id | user_id | amount | order_count | total_amount |
|---|---|---|---|---|
| 1 | U1 | 1000 | 3 | 3500 |
| 2 | U1 | 2000 | 3 | 3500 |
| 5 | U1 | 500 | 3 | 3500 |
| 3 | U2 | 1500 | 1 | 1500 |
| 4 | U3 | 3000 | 1 | 3000 |
Aggregate values are attached to each row without losing detail
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;
For every event in the user_events table below, attach the first event performed by that user as first_event.
| user_id | event_date | event_name |
|---|---|---|
| U1 | 04-01 | Registration |
| U1 | 04-02 | View |
| U1 | 04-05 | Purchase |
| U2 | 04-03 | Registration |
| user_id | event_date | event_name | first_event |
|---|---|---|---|
| U1 | 04-01 | Registration | Registration |
| U1 | 04-02 | View | Registration |
| U1 | 04-05 | Purchase | Registration |
| U2 | 04-03 | Registration | Registration |
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 */
LEGEND
① FROM
FROM user_eventsRead the table. It contains 3 events for U1 and 1 event for U2.| user_id | event_date | event_name |
|---|---|---|
| U1 | 04-01 | Registration |
| U1 | 04-02 | View |
| U1 | 04-05 | Purchase |
| U2 | 04-03 | Registration |
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.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().| event_date | event_name | MIN result (example) |
|---|---|---|
| 04-01 | Registration | Purchase |
| 04-02 | View | Purchase |
| 04-05 | Purchase | Purchase |
This example assumes a collation that sorts Purchase first. The result depends on collation, not date order.
| event_date | event_name | FIRST_VALUE result |
|---|---|---|
| 04-01 | Registration | Registration |
| 04-02 | View | Registration |
| 04-05 | Purchase | Registration |
ORDER BY event_date ASC correctly retrieves the chronologically first event
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.
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.
| user_id | visit_date |
|---|---|
| U1 | 04-01 |
| U1 | 04-05 |
| U1 | 04-10 |
| U2 | 04-02 |
| user_id | visit_date | next_visit |
|---|---|---|
| U1 | 04-01 | 04-05 |
| U1 | 04-05 | 04-10 |
| U1 | 04-10 | NULL |
| U2 | 04-02 | NULL |
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 */
LEGEND
① FROM
FROM website_visitsRead the table. It contains 3 visits for U1 and 1 visit for U2.| user_id | visit_date |
|---|---|
| U1 | 04-01 |
| U1 | 04-05 |
| U1 | 04-10 |
| U2 | 04-02 |
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.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.| user_id | visit_date | next_visit (LEAD) | days_to_next database-specific date difference |
|---|---|---|---|
| U1 | 04-01 | 04-05 | Returns after 4 days |
| U1 | 04-05 | 04-10 | Returns after 5 days |
| U1 | 04-10 | NULL | NULL (final visit in observation period) |
| U2 | 04-02 | NULL | NULL (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.
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)
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.
| date | users |
|---|---|
| 04-01 | 100 |
| 04-02 | 120 |
| 04-03 | 150 |
| date | users | prev_users |
|---|---|---|
| 04-01 | 100 | 0 |
| 04-02 | 120 | 100 |
| 04-03 | 150 | 120 |
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 */
LEGEND
① 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.| date | users |
|---|---|
| 04-01 | 100 |
| 04-02 | 120 |
| 04-03 | 150 |
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.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.| date | users | prev_users | Difference (users - prev) |
|---|---|---|---|
| 04-01 | 100 | NULL | NULL ← Cannot calculate |
| 04-02 | 120 | 100 | +20 |
| 04-03 | 150 | 120 | +30 |
The first-row difference is NULL and may be excluded from later aggregates
| date | users | prev_users | Difference (users - prev) |
|---|---|---|---|
| 04-01 | 100 | 0 | +100 ← Can calculate |
| 04-02 | 120 | 100 | +20 |
| 04-03 | 150 | 120 | +30 |
The first day is compared with 0, so a difference can be calculated for every row