SQL Window Functions: OVER, PARTITION BY, RANK and LAG
A window function calculates a value from a group of rows, but unlike GROUP BY it keeps every row in the result. That is how you put a department's average next to each employee, number rows, rank them, or add a running total, all without losing the detail rows.
What Is a Window Function?
GROUP BY answers "what is the average salary per department?" with one row per department. A window function answers the same question but writes the answer on every employee's row, so you can compare each person with their department:
SELECT first_name, department, salary,
ROUND(AVG(salary) OVER (PARTITION BY department)) AS dept_avg,
salary - ROUND(AVG(salary) OVER (PARTITION BY department)) AS difference
FROM employees
ORDER BY department, salary DESC
LIMIT 8;
▶ Run this query in the SQL Playground
| first_name | department | salary | dept_avg | difference |
|---|---|---|---|---|
| Nancy | Customer Success | 203999 | 134140 | 69859 |
| Maria | Customer Success | 191120 | 134140 | 56980 |
| Barbara | Customer Success | 188280 | 134140 | 54140 |
| Melissa | Customer Success | 178391 | 134140 | 44251 |
| Dorothy | Customer Success | 165118 | 134140 | 30978 |
| Charles | Customer Success | 162901 | 134140 | 28761 |
| Sarah | Customer Success | 152154 | 134140 | 18014 |
| Edward | Customer Success | 137737 | 134140 | 3597 |
OVER (PARTITION BY department) is what makes AVG a window function. "Window" means the set of rows the function looks at for the current row; here that is all rows of the same department. All 120 employees stay in the result.
| GROUP BY | Window function | |
|---|---|---|
| Rows in the result | One per group | Every input row |
| Detail columns | Only grouped columns and aggregates | Any column |
| Typical question | Total per month | Running total, rank, previous value, share of total |
| Written as | SUM(x) ... GROUP BY g | SUM(x) OVER (PARTITION BY g) |
The OVER Clause
function_name(arguments) OVER (
[PARTITION BY column, ...] -- split rows into groups
[ORDER BY column, ...] -- order rows inside each group
[ROWS BETWEEN ... AND ...] -- which rows around the current one to use
)
| Part | What it does | If you leave it out |
|---|---|---|
| PARTITION BY | The calculation restarts for each group | The whole result is one group |
| ORDER BY | Defines "previous", "next" and the order for ranking and running totals | Rows have no order inside the window |
| Frame (ROWS / RANGE) | Limits the rows used, such as "the last 3 rows" | With ORDER BY: from the start up to the current row. Without: the whole partition |
An empty OVER () uses all rows. That makes "share of the total" a one-liner:
SELECT category,
ROUND(SUM(price), 2) AS list_value,
ROUND(100.0 * SUM(price) / SUM(SUM(price)) OVER (), 1) AS pct_of_total
FROM products
GROUP BY category
ORDER BY pct_of_total DESC;
▶ Run this query in the SQL Playground
| category | list_value | pct_of_total |
|---|---|---|
| Food & Beverage | 21240.86 | 16.9 |
| Office Supplies | 16043.57 | 12.8 |
| Books | 15926.98 | 12.7 |
| Automotive | 12771.99 | 10.2 |
| Sports & Outdoors | 12780.25 | 10.2 |
| Home & Garden | 12426.83 | 9.9 |
| Toys | 11280.23 | 9 |
| Clothing | 10825.61 | 8.6 |
| Health & Beauty | 7219.45 | 5.7 |
| Electronics | 5268.7 | 4.2 |
SUM(SUM(price)) OVER () looks odd but is common: the inner SUM is the GROUP BY total per category, the outer SUM adds those totals across all categories. Window functions are calculated after GROUP BY, so they can use its results.
Ranking: ROW_NUMBER, RANK, DENSE_RANK and NTILE
The three ranking functions differ only in how they handle ties. Product ratings have plenty of ties, so they show the difference well:
SELECT name, rating,
ROW_NUMBER() OVER (ORDER BY rating DESC, name) AS row_num,
RANK() OVER (ORDER BY rating DESC) AS rnk,
DENSE_RANK() OVER (ORDER BY rating DESC) AS dense_rnk
FROM products
ORDER BY rating DESC, name
LIMIT 10;
▶ Run this query in the SQL Playground
| name | rating | row_num | rnk | dense_rnk |
|---|---|---|---|---|
| Japanese Green Tea 50 bags | 5 | 1 | 1 | 1 |
| Laptop Pro 15" | 5 | 2 | 1 | 1 |
| Play Kitchen Deluxe | 5 | 3 | 1 | 1 |
| Premium Beard Oil 60ml | 5 | 4 | 1 | 1 |
| SPF 50 Mineral Sunscreen | 5 | 5 | 1 | 1 |
| The Manager's Path | 5 | 6 | 1 | 1 |
| Artisan Hot Sauce Trio | 4.9 | 7 | 7 | 2 |
| The Manager's Path | 4.9 | 8 | 7 | 2 |
| The Pragmatic Programmer | 4.9 | 9 | 7 | 2 |
| Zero to One (Thiel) | 4.9 | 10 | 7 | 2 |
| Function | Ties | After a tie of six at the top |
|---|---|---|
| ROW_NUMBER() | Different numbers (order among ties needs a tie-breaker) | 1, 2, 3, 4, 5, 6, 7 ... |
| RANK() | Same number, then a gap | 1, 1, 1, 1, 1, 1, 7 ... |
| DENSE_RANK() | Same number, no gap | 1, 1, 1, 1, 1, 1, 2 ... |
Add PARTITION BY to rank within groups. Each department's salaries, ranked from the top:
SELECT department, first_name, salary,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS rank_in_dept
FROM employees
WHERE department IN ('Design', 'Product')
ORDER BY department, rank_in_dept;
▶ Run this query in the SQL Playground
| department | first_name | salary | rank_in_dept |
|---|---|---|---|
| Design | Amy | 194543 | 1 |
| Design | Joshua | 191657 | 2 |
| Design | Sandra | 187089 | 3 |
| Design | Ashley | 171755 | 4 |
| Design | Jason | 156865 | 5 |
| Design | Maria | 144481 | 6 |
| Design | Michael | 118465 | 7 |
| Design | Ashley | 75120 | 8 |
| Design | Robert | 53630 | 9 |
| Design | Paul | 46044 | 10 |
NTILE: equal-sized buckets
NTILE(n) splits the ordered rows into n groups of (nearly) equal size, for quartiles, deciles and similar bands:
SELECT quartile,
COUNT(*) AS employees,
MIN(salary) AS lowest,
MAX(salary) AS highest
FROM (SELECT salary, NTILE(4) OVER (ORDER BY salary DESC) AS quartile
FROM employees) t
GROUP BY quartile
ORDER BY quartile;
▶ Run this query in the SQL Playground
| quartile | employees | lowest | highest |
|---|---|---|---|
| 1 | 30 | 167158 | 204449 |
| 2 | 30 | 121887 | 166288 |
| 3 | 30 | 84262 | 121695 |
| 4 | 30 | 46044 | 84033 |
Each ranking function has its own lesson with more examples: ROW_NUMBER and RANK and DENSE_RANK.
Running Totals and Moving Averages
An aggregate with ORDER BY in its OVER clause becomes cumulative: each row sums everything up to and including itself. Here is revenue per month with a running total that restarts each year:
WITH monthly AS (
SELECT strftime('%Y-%m', order_date) AS month,
ROUND(SUM(total), 2) AS revenue
FROM orders
WHERE order_date >= '2023-01-01'
GROUP BY month
)
SELECT month, revenue,
ROUND(SUM(revenue) OVER (PARTITION BY substr(month, 1, 4)
ORDER BY month), 2) AS year_to_date
FROM monthly
ORDER BY month
LIMIT 8;
▶ Run this query in the SQL Playground
| month | revenue | year_to_date |
|---|---|---|
| 2023-01 | 10518.82 | 10518.82 |
| 2023-02 | 10464.5 | 20983.32 |
| 2023-03 | 23221.35 | 44204.67 |
| 2023-04 | 7355.15 | 51559.82 |
| 2023-05 | 26116.83 | 77676.65 |
| 2023-06 | 11301.86 | 88978.51 |
| 2023-07 | 8479.43 | 97457.94 |
| 2023-08 | 19166.49 | 116624.43 |
Moving average with a window frame
A frame limits the window to rows near the current one. ROWS BETWEEN 2 PRECEDING AND CURRENT ROW means "this month and the two before it", a three-month moving average:
WITH monthly AS (
SELECT strftime('%Y-%m', order_date) AS month,
ROUND(SUM(total), 2) AS revenue
FROM orders
WHERE order_date >= '2023-01-01'
GROUP BY month
)
SELECT month, revenue,
ROUND(AVG(revenue) OVER (ORDER BY month
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 2) AS avg_3_months
FROM monthly
ORDER BY month
LIMIT 8;
▶ Run this query in the SQL Playground
| month | revenue | avg_3_months |
|---|---|---|
| 2023-01 | 10518.82 | 10518.82 |
| 2023-02 | 10464.5 | 10491.66 |
| 2023-03 | 23221.35 | 14734.89 |
| 2023-04 | 7355.15 | 13680.33 |
| 2023-05 | 26116.83 | 18897.78 |
| 2023-06 | 11301.86 | 14924.61 |
| 2023-07 | 8479.43 | 15299.37 |
| 2023-08 | 19166.49 | 12982.59 |
The first two months average fewer than three values because there are not enough earlier rows yet.
| Frame | Rows used |
|---|---|
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW | From the first row to this one (a running total) |
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW | This row and the two before it |
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING | The previous, current and next row |
ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING | This row to the last one |
LAG, LEAD, FIRST_VALUE and LAST_VALUE
LAG reads a value from an earlier row and LEAD from a later one. With LAG(revenue, 12) on monthly data you compare each month with the same month a year earlier:
WITH monthly AS (
SELECT strftime('%Y-%m', order_date) AS month,
ROUND(SUM(total), 2) AS revenue
FROM orders
WHERE order_date >= '2023-01-01'
GROUP BY month
)
SELECT month, revenue,
LAG(revenue) OVER (ORDER BY month) AS previous_month,
LAG(revenue, 12) OVER (ORDER BY month) AS same_month_last_year,
ROUND(100.0 * (revenue - LAG(revenue, 12) OVER (ORDER BY month))
/ LAG(revenue, 12) OVER (ORDER BY month), 1) AS yoy_pct
FROM monthly
ORDER BY month DESC
LIMIT 6;
▶ Run this query in the SQL Playground
| month | revenue | previous_month | same_month_last_year | yoy_pct |
|---|---|---|---|---|
| 2024-12 | 10621.36 | 17380.72 | 29761.96 | -64.3 |
| 2024-11 | 17380.72 | 7996.72 | 9310.47 | 86.7 |
| 2024-10 | 7996.72 | 4319.46 | 12822.83 | -37.6 |
| 2024-09 | 4319.46 | 25429.09 | 16095.42 | -73.2 |
| 2024-08 | 25429.09 | 10296.67 | 19166.49 | 32.7 |
| 2024-07 | 10296.67 | 5186.49 | 8479.43 | 21.4 |
The LAG and LEAD lesson covers offsets, defaults and gaps between a customer's orders.
First and last value in a group
FIRST_VALUE returns the first value in the window. LAST_VALUE needs care: with ORDER BY the default frame ends at the current row, so it returns the current row unless you extend the frame to the end:
SELECT user_id, order_date, total,
FIRST_VALUE(order_date) OVER (PARTITION BY user_id ORDER BY order_date, id) AS first_order,
LAST_VALUE(order_date) OVER (PARTITION BY user_id ORDER BY order_date, id
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS last_order
FROM orders
WHERE user_id IN (4, 77)
ORDER BY user_id, order_date;
▶ Run this query in the SQL Playground
| user_id | order_date | total | first_order | last_order |
|---|---|---|---|---|
| 4 | 2022-08-22 | 2501.23 | 2022-08-22 | 2024-11-15 |
| 4 | 2024-04-20 | 843.7 | 2022-08-22 | 2024-11-15 |
| 4 | 2024-11-15 | 1518.36 | 2022-08-22 | 2024-11-15 |
| 77 | 2022-12-15 | 4595.72 | 2022-12-15 | 2024-08-16 |
| 77 | 2023-03-18 | 4770.01 | 2022-12-15 | 2024-08-16 |
| 77 | 2023-06-03 | 345.48 | 2022-12-15 | 2024-08-16 |
| 77 | 2024-01-11 | 1613.31 | 2022-12-15 | 2024-08-16 |
| 77 | 2024-08-16 | 3268.65 | 2022-12-15 | 2024-08-16 |
Common Window Function Patterns
Top N per group
Window functions cannot be used in WHERE, because WHERE runs before they are calculated. Number the rows in a subquery or CTE, then filter outside. The three newest orders per status:
SELECT status, id, order_date, total
FROM (SELECT status, id, order_date, total,
ROW_NUMBER() OVER (PARTITION BY status ORDER BY order_date DESC, id DESC) AS rn
FROM orders) t
WHERE rn <= 3
ORDER BY status, rn;
▶ Run this query in the SQL Playground
| status | id | order_date | total |
|---|---|---|---|
| cancelled | 155 | 2024-12-19 | 110.6 |
| cancelled | 22 | 2024-08-12 | 1691.82 |
| cancelled | 144 | 2024-06-28 | 3701.09 |
| delivered | 171 | 2024-12-23 | 2162.1 |
| delivered | 169 | 2024-12-12 | 1308.16 |
| delivered | 97 | 2024-12-09 | 4566.23 |
| pending | 70 | 2024-12-24 | 2104.22 |
| pending | 168 | 2024-11-01 | 4271.87 |
| pending | 112 | 2024-07-06 | 441.29 |
| processing | 11 | 2024-11-15 | 1518.36 |
Finding duplicates
Number the rows within each group of identical values; every row numbered 2 or more is a duplicate:
SELECT id, name, rn
FROM (SELECT id, name,
ROW_NUMBER() OVER (PARTITION BY name ORDER BY id) AS rn
FROM products) t
WHERE rn > 1
ORDER BY name, rn
LIMIT 8;
▶ Run this query in the SQL Playground
| id | name | rn |
|---|---|---|
| 102 | 2000A Jump Starter Pack | 2 |
| 46 | 4K Dual Dash Camera | 2 |
| 72 | 4K Dual Dash Camera | 3 |
| 50 | A5 Hardcover Notebook 3pk | 2 |
| 24 | Artisan Hot Sauce Trio | 2 |
| 40 | Artisan Hot Sauce Trio | 3 |
| 67 | Artisan Hot Sauce Trio | 4 |
| 75 | Artisan Hot Sauce Trio | 5 |
Several windows in one query
Each window function has its own OVER clause, so different partitions can sit side by side:
SELECT first_name, department, salary,
RANK() OVER (ORDER BY salary DESC) AS company_rank,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS dept_rank,
COUNT(*) OVER (PARTITION BY department) AS dept_size
FROM employees
ORDER BY company_rank
LIMIT 6;
▶ Run this query in the SQL Playground
| first_name | department | salary | company_rank | dept_rank | dept_size |
|---|---|---|---|---|---|
| Steven | Marketing | 204449 | 1 | 1 | 13 |
| Nancy | Customer Success | 203999 | 2 | 1 | 15 |
| John | Operations | 203467 | 3 | 1 | 13 |
| Donna | Engineering | 199029 | 4 | 1 | 11 |
| Matthew | Marketing | 196530 | 5 | 2 | 13 |
| Paul | Finance | 196032 | 6 | 1 | 10 |
When Window Functions Are Calculated
| Step | Clause |
|---|---|
| 1 | FROM and JOIN |
| 2 | WHERE |
| 3 | GROUP BY |
| 4 | HAVING |
| 5 | Window functions (in SELECT) |
| 6 | DISTINCT |
| 7 | ORDER BY |
| 8 | LIMIT |
Two consequences follow. A window function cannot appear in WHERE, GROUP BY or HAVING; wrap the query and filter outside. And WHERE removes rows before the window is calculated, so filtering first changes the result: a LAG on a query filtered to one month has no previous row to read.
Window Functions in Each Database
| Database | Support |
|---|---|
| PostgreSQL | Full, since 8.4. Also FILTER, GROUPS frames and named windows (WINDOW w AS ...) |
| SQL Server | Ranking since 2005; LAG, LEAD, FIRST_VALUE and frames since 2012 |
| Oracle | Full (it calls them analytic functions) |
| MySQL | From 8.0 only; MySQL 5.7 has none |
| SQLite | From 3.25 (the Playground runs 3.49) |
Window Functions and Performance
Most window functions need the rows sorted by the PARTITION BY and ORDER BY columns. That sort is usually the expensive part.
| Tip | Why |
|---|---|
| Filter with WHERE in an inner query first, when the logic allows it | Fewer rows to sort |
| An index on (partition columns, order columns) can remove the sort | The rows can be read in order |
| Reuse the same OVER clause where possible | Windows with identical definitions can share one sort |
| Prefer one window function to a correlated subquery on large tables | One pass instead of one lookup per row |
Common Window Function Mistakes
Six errors and how to avoid them.
1. Using a window function in WHERE
SELECT *
FROM employees
WHERE ROW_NUMBER() OVER (ORDER BY salary DESC) <= 3;
SELECT *
FROM (SELECT e.*, ROW_NUMBER() OVER (ORDER BY salary DESC) AS rn
FROM employees e) t
WHERE rn <= 3;
WHERE is evaluated before window functions exist.
2. ROW_NUMBER without a deterministic ORDER BY
ROW_NUMBER() OVER (ORDER BY rating DESC)
ROW_NUMBER() OVER (ORDER BY rating DESC, id)
Six products share the top rating. Without a tie-breaker their numbers can change from one run to the next.
3. LAST_VALUE with the default frame
LAST_VALUE(order_date) OVER (PARTITION BY user_id ORDER BY order_date)
LAST_VALUE(order_date) OVER (PARTITION BY user_id ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)
With ORDER BY, the default frame stops at the current row, so the "last" value is the current one.
4. Forgetting PARTITION BY
RANK() OVER (ORDER BY salary DESC)
RANK() OVER (PARTITION BY department ORDER BY salary DESC)
Without PARTITION BY the window is all rows, so the numbering never restarts.
5. Expecting GROUP BY behaviour
SELECT department, AVG(salary) OVER (PARTITION BY department)
FROM employees;
SELECT department, AVG(salary)
FROM employees
GROUP BY department;
A window function keeps every row. If you want one row per group, use GROUP BY (or add DISTINCT).
6. Filtering away the rows LAG needs
SELECT month, LAG(revenue) OVER (ORDER BY month) AS previous
FROM monthly
WHERE month = '2024-12';
SELECT * FROM (
SELECT month, LAG(revenue) OVER (ORDER BY month) AS previous
FROM monthly) t
WHERE month = '2024-12';
WHERE runs before the window, so November is gone before LAG looks for it.
Window Functions Cheat Sheet
| Task | Pattern |
|---|---|
| Group value on every row | AVG(salary) OVER (PARTITION BY department) |
| Share of total | x / SUM(x) OVER () |
| Number rows | ROW_NUMBER() OVER (ORDER BY x, id) |
| Rank with ties | RANK() or DENSE_RANK() OVER (ORDER BY x DESC) |
| Buckets | NTILE(4) OVER (ORDER BY x) |
| Running total | SUM(x) OVER (ORDER BY d ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) |
| Moving average | AVG(x) OVER (ORDER BY d ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) |
| Previous / next | LAG(x) OVER (ORDER BY d), LEAD(x) OVER (ORDER BY d) |
| Top N per group | ROW_NUMBER() OVER (PARTITION BY g ORDER BY x DESC), then WHERE rn <= N outside |
Practice Window Functions Queries
8 exercises on the real Playground tables.
Write each query yourself first. Every solution has been run against the Playground database.
-
1Easy Show each product with the average price of its category next to it. Show 8, ordered by category and price. Table:
productsShow solution
SELECT name, category, price, ROUND(AVG(price) OVER (PARTITION BY category), 2) AS category_avg FROM products ORDER BY category, price DESC LIMIT 8;▶ Run this query in the SQL Playground
PARTITION BY category computes one average per category and repeats it on each row.
-
2Easy Number the employees from the most recently hired (1) to the earliest. Show the first 5. Table:
employeesShow solution
SELECT ROW_NUMBER() OVER (ORDER BY hire_date DESC, id) AS n, first_name, last_name, hire_date FROM employees ORDER BY n LIMIT 5;▶ Run this query in the SQL Playground
id breaks ties between employees hired on the same day.
-
3Medium For each customer's orders, show a running total of what they have spent so far. Use customers 4 and 77. Table:
ordersShow solution
SELECT user_id, order_date, total, ROUND(SUM(total) OVER (PARTITION BY user_id ORDER BY order_date, id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW), 2) AS spent_so_far FROM orders WHERE user_id IN (4, 77) ORDER BY user_id, order_date;▶ Run this query in the SQL Playground
The running total restarts for each customer because of PARTITION BY user_id.
-
4Medium Show the cheapest product in each category. Table:
productsShow solution
SELECT category, name, price FROM (SELECT category, name, price, ROW_NUMBER() OVER (PARTITION BY category ORDER BY price, id) AS rn FROM products) t WHERE rn = 1 ORDER BY price;▶ Run this query in the SQL Playground
Number within each category from the lowest price and keep number 1.
-
5Medium Show each rating value with its number of reviews and its share of all reviews in percent. Table:
reviewsShow solution
SELECT rating, COUNT(*) AS reviews, ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) AS pct FROM reviews GROUP BY rating ORDER BY rating DESC;▶ Run this query in the SQL Playground
SUM(COUNT(*)) OVER () adds up the grouped counts across all ratings.
-
6Medium Show each Design employee with the salary just below and just above theirs in the same department. Table:
employeesShow solution
SELECT first_name, salary, LAG(salary) OVER (PARTITION BY department ORDER BY salary) AS next_lower, LEAD(salary) OVER (PARTITION BY department ORDER BY salary) AS next_higher FROM employees WHERE department = 'Design' ORDER BY salary;▶ Run this query in the SQL Playground
Ordered by salary, LEAD looks one step up and LAG one step down.
-
7Hard Find the month with the highest revenue in each year. Table:
ordersShow solution
WITH monthly AS ( SELECT strftime('%Y', order_date) AS yr, strftime('%Y-%m', order_date) AS month, ROUND(SUM(total), 2) AS revenue FROM orders GROUP BY yr, month ) SELECT yr, month, revenue FROM (SELECT *, RANK() OVER (PARTITION BY yr ORDER BY revenue DESC) AS rnk FROM monthly) t WHERE rnk = 1 ORDER BY yr;▶ Run this query in the SQL Playground
Group to months first, then rank the months within each year.
-
8Hard Split customers who have ordered into four groups by total spend (1 = highest) and show each group's size and spend range. Table:
users, ordersShow solution
SELECT spend_quartile, COUNT(*) AS customers, MIN(spent) AS lowest, MAX(spent) AS highest FROM (SELECT user_id, ROUND(SUM(total), 2) AS spent, NTILE(4) OVER (ORDER BY SUM(total) DESC) AS spend_quartile FROM orders GROUP BY user_id) t GROUP BY spend_quartile ORDER BY spend_quartile;▶ Run this query in the SQL Playground
NTILE runs after GROUP BY, so it can order by the grouped total.
📝 Quiz — Window Functions
Test your understanding · 0/41 answered
What is the defining feature of a window function?
That is the difference from GROUP BY.
What does the OVER clause do?
PARTITION BY, ORDER BY and a frame all go inside OVER.
What is the main difference between GROUP BY and window functions?
AVG(salary) OVER (PARTITION BY department) keeps all 120 employees.
What does ROW_NUMBER() OVER (ORDER BY salary DESC) produce?
Add a tie-breaker so equal salaries are numbered consistently.
How does RANK() treat ties?
Six products tied first, so the next one is ranked 7.
What is the difference between RANK and DENSE_RANK?
After the tie of six, DENSE_RANK continues with 2.
What does PARTITION BY do?
It is like GROUP BY without collapsing the rows.
What does SUM(total) OVER (ORDER BY order_date) compute?
ORDER BY inside OVER makes the sum cumulative.
How is a moving average built?
The frame limits the average to nearby rows.
What does LAG(revenue) OVER (ORDER BY month) return?
LAG looks back; the first row gets NULL.
What does LEAD(revenue) OVER (ORDER BY month) return?
LEAD looks forward; the last row gets NULL.
What does FIRST_VALUE(total) OVER (PARTITION BY user_id ORDER BY order_date) return?
The first row of each partition in the given order.
What does LAST_VALUE usually need to work as expected?
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
What does NTILE(4) OVER (ORDER BY salary DESC) do?
Quartiles; NTILE(10) gives deciles.
What is a window frame?
Frames make moving averages possible.
What does ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW mean?
That is a running total frame.
How do you get the top 3 products per category?
The top-N pattern on this page does this for order statuses.
How can a window function find duplicates?
Every row after the first in its group is a duplicate.
What is month-over-month growth usually built on?
See the year-over-year example, which uses LAG(revenue, 12).
Can a window function be used directly in WHERE?
WHERE runs before window functions are calculated.
Which is a typical window function interview question?
Both appear on this page with real output.
How do you compute a rolling 7-day average of daily sales?
Six previous rows plus the current one make seven, assuming one row per day.
Why do analysts use window functions so much?
Most report logic beyond simple totals uses them.
What does COUNT(*) OVER () return?
An empty OVER uses all rows.
Do all window functions need ORDER BY inside OVER?
AVG(salary) OVER (PARTITION BY department) has no ORDER BY.
What is a common ROW_NUMBER mistake?
Add a unique column such as id to the ORDER BY.
What is year-over-year growth on monthly data usually built with?
That assumes one row per month with no missing months.
How can you show each customer's first and last purchase date in one query?
MIN and MAX OVER (PARTITION BY user_id) also work for dates.
What is cohort analysis, and how do window functions help?
MIN(order_date) OVER (PARTITION BY user_id) gives each customer's cohort date.
What can EXPLAIN show about a query with ROW_NUMBER() OVER (ORDER BY salary DESC)?
The sort is usually the expensive part.
Are window functions standard SQL?
MySQL 5.7 is the main exception.
How does PARTITION BY differ from GROUP BY?
Same grouping idea, different shape of result.
How does RANGE BETWEEN differ from ROWS BETWEEN?
The default frame with ORDER BY is RANGE, which matters when values repeat.
How do you show each row's percentage of the total?
The category share example on this page does this.
What does ORDER BY inside OVER do for a running total?
Change the ORDER BY and the running totals change.
How do you rank employees within each department by salary?
PARTITION BY restarts the ranking per department.
How do window functions help with funnel analysis?
Ordering each user's events and comparing neighbours is the core of a funnel query.
Which function splits customers into 10 equal groups by value?
NTILE(10) OVER (ORDER BY lifetime_value DESC).
Can two window functions in one SELECT use different PARTITION BY clauses?
The example with company rank and department rank does exactly that.
How could you find employees whose salary has not changed in two years, given a salary history table?
Partition the history by employee and order it by date.
How important are window functions in interviews?
Ranking, top N per group, running totals and LAG comparisons come up very often.
Window Functions Interview Questions
Q1. What is the difference between a window function and GROUP BY?
GROUP BY collapses each group into one row. A window function calculates over a group of rows but keeps every row, so detail and group values can appear together.
Q2. ROW_NUMBER, RANK or DENSE_RANK?
ROW_NUMBER for exactly one row per position, for example "the latest order per customer". RANK when ties should share a place and leave a gap, as in sports results. DENSE_RANK when ties share a place without gaps, as in "the Nth highest distinct salary".
Q3. Why can a window function not be used in WHERE?
WHERE is evaluated before the SELECT list, where window functions are computed. Compute the value in a subquery or CTE and filter in the outer query.
Q4. What is the default window frame?
With ORDER BY: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Without ORDER BY: the whole partition. This is why LAST_VALUE needs an explicit frame.
Q5. How do you calculate a running total that restarts each year?
SUM(revenue) OVER (PARTITION BY year ORDER BY month ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW).
Q6. How do you get the top 3 per group?
Number the rows with ROW_NUMBER() OVER (PARTITION BY group ORDER BY value DESC) in a subquery or CTE and keep rn <= 3. Use RANK instead if ties should all be included.
Q7. How do you compute month-over-month change?
Aggregate to one row per month, then revenue - LAG(revenue) OVER (ORDER BY month), and divide by the LAG value for a percentage. The first month has no previous value and gives NULL.
Frequently Asked Questions
What are window functions in SQL?
Window functions calculate a value across a set of related rows while still returning one row per input row. Unlike GROUP BY, they never collapse the result.
What is the OVER clause?
OVER turns an ordinary function into a window function and defines which rows it uses, through optional PARTITION BY (groups), ORDER BY (order inside the group) and a frame (which nearby rows).
What is ROW_NUMBER?
ROW_NUMBER() gives each row in its window a unique sequential number starting at 1, with no ties and no gaps.
What is the difference between RANK and DENSE_RANK?
Both give tied rows the same rank. RANK then skips numbers (1, 1, 3) while DENSE_RANK does not (1, 1, 2).
What is PARTITION BY?
PARTITION BY splits the rows into groups so the window function restarts in each group. It is similar to GROUP BY but keeps all the rows.
What are LAG and LEAD?
LAG returns a value from an earlier row and LEAD from a later row in the same ordered window. They are used for comparisons such as this month versus last month.
Why are window functions important?
They are how SQL produces rankings, running totals, moving averages, shares of a total and period-over-period comparisons, which most reports need.
Are window functions asked in SQL interviews?
Yes. Ranking, top N per group, running totals and LAG/LEAD comparisons are among the most common interview topics for analyst, BI and data engineering roles.