What is a window function, and how is it different from GROUP BY?
A window function computes a value over related rows but keeps every row;
GROUP BYcollapses each group into one row.SQLCREATE TABLE employees (id INTEGER PRIMARY KEY, name TEXT, dept TEXT, salary INTEGER, manager_id INTEGER); INSERT INTO employees VALUES (1, 'Asha', 'Engineering', 120000, NULL), (2, 'Ravi', 'Engineering', 90000, 1), (3, 'Meera', 'Engineering', 90000, 1), (4, 'John', 'Sales', 70000, 1), (5, 'Priya', 'Sales', 82000, 4), (6, 'Karan', 'HR', 60000, 1); SELECT name, dept, salary, AVG(salary) OVER (PARTITION BY dept) AS dept_avg, salary - AVG(salary) OVER (PARTITION BY dept) AS diff FROM employees ORDER BY dept, salary DESC, name;Each employee keeps a row with the department average beside it. Inside
OVER,PARTITION BYsplits the groups,ORDER BYorders rows within them, and an optional frame (ROWS BETWEEN ...) limits which rows count. See window functions.How do
ROW_NUMBER,RANKandDENSE_RANKnumber tied rows?SQLCREATE TABLE scores (name TEXT, score INTEGER); INSERT INTO scores VALUES ('Asha', 100), ('Ravi', 90), ('Meera', 90), ('John', 80); SELECT rn, rnk, drnk FROM ( SELECT name, ROW_NUMBER() OVER (ORDER BY score DESC) AS rn, RANK() OVER (ORDER BY score DESC) AS rnk, DENSE_RANK() OVER (ORDER BY score DESC) AS drnk FROM scores ) AS t WHERE name = 'John';They differ only on ties.
ROW_NUMBERalways counts 1, 2, 3, 4;RANKgives tied rows the same number and then skips;DENSE_RANKrepeats the number without skipping. So John gets4 | 4 | 3.SQLCREATE TABLE scores (name TEXT, score INTEGER); INSERT INTO scores VALUES ('Asha', 100), ('Ravi', 90), ('Meera', 90), ('John', 80); SELECT name, score, ROW_NUMBER() OVER (ORDER BY score DESC, name) AS row_number, RANK() OVER (ORDER BY score DESC) AS rank, DENSE_RANK() OVER (ORDER BY score DESC) AS dense_rank FROM scores ORDER BY score DESC, name;Give
ROW_NUMBERa tiebreaker, or which tied row gets 2 is arbitrary. UseROW_NUMBERto pick one row per group andDENSE_RANKfor the Nth distinct value.Practice it: Kth Largest Element in an Array
How do you calculate a running total in SQL?
Use
SUM(...) OVER (ORDER BY ...): each row gets the sum of all rows up to and including itself.SQLCREATE TABLE daily_sales (day TEXT, amount INTEGER); INSERT INTO daily_sales VALUES ('2026-01-01', 100), ('2026-01-02', 250), ('2026-01-03', 50), ('2026-01-04', 300); SELECT day, amount, SUM(amount) OVER (ORDER BY day) AS running_total, MIN(amount) OVER (ORDER BY day) AS lowest_so_far FROM daily_sales ORDER BY day;The total ends at 700. The same pattern with
MINgives the lowest value so far, the core of the "best time to buy and sell a stock" problem. AddPARTITION BY customer_idto restart the total per customer.Practice it: Running Sum of an Array, Best Time to Buy and Sell Stock
What do LAG and LEAD do? Show the change from the previous day.
LAG(col)returns the previous row's value in the window order andLEAD(col)the next row's, so you compare neighbors without a self join.SQLCREATE TABLE daily_sales (day TEXT, amount INTEGER); INSERT INTO daily_sales VALUES ('2026-01-01', 100), ('2026-01-02', 250), ('2026-01-03', 50), ('2026-01-04', 300); SELECT day, amount, amount - LAG(amount) OVER (ORDER BY day) AS change, LEAD(amount) OVER (ORDER BY day) AS next_day FROM daily_sales ORDER BY day;The first row's change is NULL because it has no previous day;
LAG(amount, 1, 0)returns 0 instead. Typical uses are month-over-month growth and time between sessions.What does a running
SUM() OVER (ORDER BY day)do with tied days?SQLCREATE TABLE sales (id INTEGER, day INTEGER, amount INTEGER); INSERT INTO sales VALUES (1, 1, 10), (2, 2, 20), (3, 2, 30), (4, 3, 40); SELECT running FROM ( SELECT id, SUM(amount) OVER (ORDER BY day) AS running FROM sales ) AS t WHERE id = 2;It prints 60, not 30. With
ORDER BYand no frame, the default isRANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, which includes every row with the sameday. Rows 2 and 3 share day 2, so both get10 + 20 + 30. For a row-by-row total, order by a unique key (ORDER BY day, id) or useROWS.How do you calculate a moving average, for example over the last 3 days?
Use a
ROWSframe:AVG(amount) OVER (ORDER BY day ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)averages the current row and the two before it.SQLCREATE TABLE daily_sales (day TEXT, amount INTEGER); INSERT INTO daily_sales VALUES ('2026-01-01', 100), ('2026-01-02', 250), ('2026-01-03', 50), ('2026-01-04', 300); SELECT day, amount, ROUND(AVG(amount) OVER ( ORDER BY day ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ), 1) AS avg_3_days FROM daily_sales ORDER BY day;The first two rows average fewer values (100.0, then 175.0).
ROWScounts rows, so a missing day widens the window; fill gaps with a calendar table first.Practice it: Maximum Sum Subarray of Size K
Why does LAST_VALUE return the current row instead of the last row?
The default frame ends at the current row, so
LAST_VALUEreturns the current row's value. Extend the frame to the end of the partition:SQLCREATE TABLE scores (name TEXT, score INTEGER); INSERT INTO scores VALUES ('Asha', 100), ('Ravi', 90), ('John', 80); SELECT name, score, LAST_VALUE(name) OVER (ORDER BY score) AS default_frame, LAST_VALUE(name) OVER ( ORDER BY score ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS whole_partition FROM scores ORDER BY score;default_framerepeats each row's own name;whole_partitionshows Asha on every row. Simpler:FIRST_VALUE(name) OVER (ORDER BY score DESC), since the default frame starts at the first row.Can you use a window function in a WHERE clause?
No. Window functions run after
WHERE,GROUP BYandHAVING, so compute the window in a CTE or subquery and filter outside it.SQLCREATE TABLE employees (id INTEGER PRIMARY KEY, name TEXT, dept TEXT, salary INTEGER, manager_id INTEGER); INSERT INTO employees VALUES (1, 'Asha', 'Engineering', 120000, NULL), (2, 'Ravi', 'Engineering', 90000, 1), (3, 'Meera', 'Engineering', 90000, 1), (4, 'John', 'Sales', 70000, 1), (5, 'Priya', 'Sales', 82000, 4), (6, 'Karan', 'HR', 60000, 1); WITH t AS ( SELECT name, salary, AVG(salary) OVER (PARTITION BY dept) AS dept_avg FROM employees ) SELECT name, salary, dept_avg FROM t WHERE salary > dept_avg ORDER BY salary DESC;Snowflake, BigQuery, Databricks and DuckDB have a
QUALIFYclause for this; PostgreSQL, MySQL, SQL Server and SQLite do not.What does
NTILE(n)do, and what happens when the rows do not divide evenly?NTILE(n)splits the ordered rows intonbuckets of nearly equal size and returns each row's bucket number, soNTILE(4)gives quartiles.SQLCREATE TABLE scores (name TEXT, score INTEGER); INSERT INTO scores VALUES ('Asha', 95), ('Ravi', 88), ('Meera', 76), ('John', 64), ('Priya', 59), ('Karan', 41), ('Dev', 30); SELECT name, score, NTILE(3) OVER (ORDER BY score DESC) AS band FROM scores ORDER BY score DESC;Seven rows in three buckets gives sizes 3, 2 and 2: the first buckets take the extra rows. Tied values can land in different buckets, because
NTILEcounts rows, not values. For a position based on values, usePERCENT_RANK()orCUME_DIST().What is the difference between ROWS and RANGE in a window frame?
ROWScounts physical rows around the current one;RANGEtakes every row whoseORDER BYvalue lies within an offset of the current value. They differ when the values have gaps or ties.SQLCREATE TABLE readings (day INTEGER, amount INTEGER); INSERT INTO readings VALUES (1, 10), (2, 20), (4, 40), (5, 50); SELECT day, amount, SUM(amount) OVER (ORDER BY day ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS last_2_rows, SUM(amount) OVER (ORDER BY day RANGE BETWEEN 1 PRECEDING AND CURRENT ROW) AS last_2_days FROM readings ORDER BY day;On day 4,
last_2_rowsadds days 2 and 4 (60), whilelast_2_dayscovers days 3 and 4 and finds only 40, because day 3 is missing.RANGEwith a numeric offset works in PostgreSQL 11+, MySQL 8 and SQLite 3.28+; SQL Server allowsRANGEonly withUNBOUNDEDandCURRENT ROW.
SQL Window Functions Interview Questions
ROW_NUMBER, RANK, DENSE_RANK, running totals, LAG and LEAD, and the frame rules that catch experienced developers.