Like GROUP BY, window functions aggregate grouped data, but they have one exceptionally powerful property: they return aggregate results while preserving every original row instead of collapsing rows.
SELECT name, amount, SUM(amount) OVER() AS total -- OVER() marks this as a window function FROM sales;
OVER() after a function such as SUM or AVG treats the entire result set as one window and attaches the same aggregate value to every row.Using the sales table below, which contains individual sales records, return each representative's sales amount, the overall sales total, and the row's share of that total.
Calculate the share as amount / overall total, round it to four decimal places, and sort the results by amount from highest to lowest.
| emp_id | emp_name | amount |
|---|---|---|
| E1 | Tanaka | 150000 |
| E2 | Sato | 200000 |
| E3 | Suzuki | 120000 |
| E4 | Takahashi | 180000 |
| emp_name | amount | total_amount | ratio |
|---|---|---|---|
| Sato | 200000 | 650000 | 0.3077 |
| Takahashi | 180000 | 650000 | 0.2769 |
| Tanaka | 150000 | 650000 | 0.2308 |
| Suzuki | 120000 | 650000 | 0.1846 |
SELECT emp_name, amount, SUM(amount) OVER() AS total_amount, -- Attach the table-wide amount total (650,000) to every row ROUND(amount * 1.0 / SUM(amount) OVER(), 4) AS ratio -- Round the share of total to four decimal places FROM sales ORDER BY amount DESC; /* Logical evaluation order: 1. FROM sales → Read 4 rows 2. WHERE/GROUP BY (if any) → Filter or aggregate rows 3. Window function → Calculate the total and attach it to each row 4. SELECT → Calculate and return the share 5. ORDER BY → Sort by sales descending */
LEGEND
① FROM
FROM salesRead all 4 rows from the sales table.| emp_name | amount |
|---|---|
| Tanaka | 150000 |
| Sato | 200000 |
| Suzuki | 120000 |
| Takahashi | 180000 |
OVER() produces both in one query.WHERE amount > 10000, the total includes only rows above 10,000.| SUM(amount) |
|---|
| 650,000 |
Individual sales details disappear
| emp_name | amount | SUM(amount) OVER() |
|---|---|---|
| Tanaka | 150,000 | 650,000 |
| Sato | 200,000 | 650,000 |
| Suzuki | 120,000 | 650,000 |
| Takahashi | 180,000 | 650,000 |
Each row can calculate its share directly
WHERE SUM(amount) OVER() > 500000 causes an error because WHERE is evaluated before window functions. To filter by a window-function result, use the subquery (or CTE) pattern covered in Q8.Writing PARTITION BY and a column name inside OVER() divides the window, or aggregation scope, by each value in that column. It resembles GROUP BY, but it still does not collapse rows.
SELECT dept, name, salary, SUM(salary) OVER(PARTITION BY dept) AS dept_total FROM employees;
From the employees table below, return each employee's name, department, salary, and the total salary for that employee's department.
| emp_name | dept | salary |
|---|---|---|
| Tanaka | Sales | 300000 |
| Sato | Sales | 280000 |
| Suzuki | Engineering | 400000 |
| Takahashi | Engineering | 350000 |
| Ito | HR | 320000 |
| emp_name | dept | salary | dept_total |
|---|---|---|---|
| Tanaka | Sales | 300000 | 580000 |
| Sato | Sales | 280000 | 580000 |
| Suzuki | Engineering | 400000 | 750000 |
| Takahashi | Engineering | 350000 | 750000 |
| Ito | HR | 320000 | 320000 |
SELECT emp_name, dept, salary, SUM(salary) OVER(PARTITION BY dept) AS dept_total -- Attach each department's salary total to every row in it FROM employees ORDER BY CASE dept WHEN 'Sales' THEN 1 WHEN 'Engineering' THEN 2 WHEN 'HR' THEN 3 END, salary; /* Logical evaluation order: 1. FROM employees → Read 5 rows 2. Window function → Partition by dept and calculate SUM within each partition 3. SELECT → Attach the calculated dept_total to each output row 4. ORDER BY → Sort departments as Sales, Engineering, and HR, then by salary ascending */
LEGEND
① FROM
FROM employeesRead all 5 rows from the employees table.| emp_name | dept | salary |
|---|---|---|
| Tanaka | Sales | 300000 |
| Sato | Sales | 280000 |
| Suzuki | Engineering | 400000 |
| Takahashi | Engineering | 350000 |
| Ito | HR | 320000 |
PARTITION BY year, month, you can attach the total for each year-month to every row.| dept (partition key) | emp_name | salary | dept_total (SUM within partition) |
|---|---|---|---|
| Sales | Tanaka | 300,000 | 580,000 |
| Sales | Sato | 280,000 | 580,000 |
| Engineering | Suzuki | 400,000 | 750,000 |
| Engineering | Takahashi | 350,000 | 750,000 |
| HR | Ito | 320,000 | 320,000 |
The row count remains 5.
SELECT dept, SUM(salary) OVER(PARTITION BY dept) FROM employees GROUP BY dept; is incorrect. GROUP BY collapses rows, so combining it this way with OVER(PARTITION BY) produces unintended results or an error. For a simple group total, use ordinary SUM() ... GROUP BY.Specifying ORDER BY and a column inside OVER changes the calculation from an overall aggregate to a running aggregate over the range from the first row through the current row.
SELECT date, amount, SUM(amount) OVER(ORDER BY date ASC) AS running_total FROM daily_sales;
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW: from the start through all rows whose sort value equals the current row's value. This produces a running total, and rows sharing the same date are included in the same frame.From the daily_sales table below, return each date's sales amount and the cumulative sales through that date as running_total.
| sale_date | amount |
|---|---|
| 04-01 | 10000 |
| 04-02 | 15000 |
| 04-03 | 12000 |
| 04-04 | 20000 |
| sale_date | amount | running_total |
|---|---|---|
| 04-01 | 10000 | 10000 |
| 04-02 | 15000 | 25000 |
| 04-03 | 12000 | 37000 |
| 04-04 | 20000 | 57000 |
SELECT sale_date, amount, SUM(amount) OVER(ORDER BY sale_date ASC) AS running_total -- Running total from the start through the current date FROM daily_sales ORDER BY sale_date; /* Logical evaluation order: 1. FROM daily_sales 2. Window function evaluates rows in sale_date order 3. SELECT returns the result */
LEGEND
① FROM
FROM daily_salesRead the table.| sale_date | amount |
|---|---|
| 04-01 | 10000 |
| 04-02 | 15000 |
| 04-03 | 12000 |
| 04-04 | 20000 |
SUM(amount) OVER(PARTITION BY dept ORDER BY date) calculates a separate running sales total for each department. This combination is essential in production dashboard queries.ORDER BY date, id.| sale_date | amount | Frame (start through current row) | running_total |
|---|---|---|---|
| 04-01 | 10,000 | 10,000 | 10,000 |
| 04-02 | 15,000 | 10,000 + 15,000 | 25,000 |
| 04-03 | 12,000 | 10,000 + 15,000 + 12,000 | 37,000 |
| 04-04 | 20,000 | 10,000 + 15,000 + 12,000 + 20,000 | 57,000 |
Because these dates are unique, the frame expands by one row at a time; summing every row in it produces the running total.
ORDER BY month, the default RANGE frame adds every row in the same month together, rather than producing the intended row-by-row running total. Add a unique sort key when row-level progression is required.ROW_NUMBER() is a window function that assigns each row a unique sequential number—1, 2, 3, and so on—in the specified order. It takes no arguments.
SELECT user_id, login_time, ROW_NUMBER() OVER( PARTITION BY user_id -- Reset for each user and restart at 1 ORDER BY login_time DESC -- Number from newest to oldest ) AS rn FROM logins;
rn=1 from the query above (covered in detail in Q8). First, master how the numbering works.The logins table below contains user login history.
For each user_id, assign row numbers rn of 1, 2, 3, and so on in descending order from the newest login_time.
| user_id | login_time |
|---|---|
| U1 | 2024-04-01 10:00 |
| U1 | 2024-04-02 12:00 |
| U1 | 2024-04-03 09:00 |
| U2 | 2024-04-01 11:00 |
| U2 | 2024-04-04 15:00 |
| user_id | login_time | rn |
|---|---|---|
| U1 | 2024-04-03 09:00 | 1 |
| U1 | 2024-04-02 12:00 | 2 |
| U1 | 2024-04-01 10:00 | 3 |
| U2 | 2024-04-04 15:00 | 1 |
| U2 | 2024-04-01 11:00 | 2 |
SELECT user_id, login_time, ROW_NUMBER() OVER( PARTITION BY user_id -- Number each user_id independently ORDER BY login_time DESC -- Descending time order makes the newest row 1 ) AS rn FROM logins ORDER BY user_id, rn; /* Logical evaluation order: 1. FROM logins 2. Create the group for user_id 'U1' 3. Create the group for user_id 'U2' 4. SELECT returns the result */
LEGEND
① FROM
FROM loginsRead the table.| user_id | login_time |
|---|---|
| U1 | 04-01 10:00 |
| U1 | 04-02 12:00 |
| U1 | 04-03 09:00 |
| U2 | 04-01 11:00 |
| U2 | 04-04 15:00 |
MAX(login_time) GROUP BY user_id can find the latest time per group, but it cannot simultaneously return other columns, such as the device or IP address used for the login. Assigning ROW_NUMBER and later filtering with WHERE rn = 1 retrieves the entire latest row and solves this problem.SELECT user_id, MAX(login_time) FROM logins GROUP BY user_id;
| user_id | MAX(login_time) |
|---|---|
| U1 | 04-03 09:00 |
| U2 | 04-04 15:00 |
SELECT * FROM ( SELECT *, ROW_NUMBER() OVER( PARTITION BY user_id ORDER BY login_time DESC ) AS rn FROM logins ) t WHERE rn = 1;
| user_id | login_time | rn |
|---|---|---|
| U1 | 04-03 09:00 | 1 |
| U2 | 04-04 15:00 | 1 |
ROW_NUMBER() OVER() numbers the entire table in an unspecified order. When the requirement says “within each group” and “in this order,” specify both PARTITION BY and ORDER BY.Three functions assign rankings, and they differ in how the next number advances after a tie, such as equal scores.
ROW_NUMBER(): always assigns 1, 2, 3, 4 in sequence, distinguishing even tied rowsRANK(): gives tied rows the same rank and leaves a gap afterward (1, 1, 3, 4)DENSE_RANK(): gives tied rows the same rank without leaving a gap afterward (1, 1, 2, 3)
Using the student test scores in the scores table below, calculate RANK(), DENSE_RANK(), and ROW_NUMBER() from highest to lowest score and compare their results.
| student | score |
|---|---|
| Student A | 95 |
| Student B | 95 |
| Student C | 88 |
| Student D | 88 |
| Student E | 70 |
| student | score | rnk | dense_rnk | row_num |
|---|---|---|---|---|
| Student A | 95 | 1 | 1 | 1 |
| Student B | 95 | 1 | 1 | 2 |
| Student C | 88 | 3 | 2 | 3 |
| Student D | 88 | 3 | 2 | 4 |
| Student E | 70 | 5 | 3 | 5 |
SELECT student, score, RANK() OVER(ORDER BY score DESC) AS rnk, -- Leave a gap after ties (competition ranking) DENSE_RANK() OVER(ORDER BY score DESC) AS dense_rnk, -- Do not leave a gap after ties ROW_NUMBER() OVER(ORDER BY score DESC, student) AS row_num -- Break ties by student for deterministic numbering FROM scores -- Assign a unique sequential number even to tied rows ORDER BY score DESC, student; /* Logical evaluation order: 1. FROM scores 2. Window functions evaluate rows by score descending 3. SELECT returns the result */
LEGEND
① FROM
FROM scoresRead the table.| student | score |
|---|---|
| Student A | 95 |
| Student B | 95 |
| Student C | 88 |
| Student D | 88 |
| Student E | 70 |
| student | score | RANK() Leaves gaps after ties |
DENSE_RANK() No gaps after ties |
ROW_NUMBER() Always unique and sequential |
|---|---|---|---|---|
| Student A | 95 | 1 | 1 | 1 |
| Student B | 95 | 1 ← tied | 1 ← tied | 2 ← tie broken |
| Student C | 88 | 3 ← rank 2 skipped | 2 ← no gap | 3 |
| Student D | 88 | 3 ← tied | 2 ← tied | 4 ← tie broken |
| Student E | 70 | 5 ← rank 4 skipped | 3 | 5 |