OVER Clause
OVER Clause
The OVER clause defines the window over which a window function operates. It specifies how rows are partitioned and ordered for calculation. Unlike GROUP BY, window functions do not collapse rows.
-- Basic OVER clause: calculate running total
SELECT
order_id,
customer_id,
total_amount,
SUM(total_amount) OVER (ORDER BY order_date) AS running_total
FROM orders;
The OVER clause can contain two optional clauses:
- PARTITION BY: Divides rows into groups (like GROUP BY but without collapsing)
- ORDER BY: Orders rows within each partition
-- PARTITION BY: calculate total per customer without collapsing
SELECT
order_id,
customer_id,
total_amount,
SUM(total_amount) OVER (PARTITION BY customer_id) AS customer_total
FROM orders;
-- Both PARTITION BY and ORDER BY
SELECT
order_id,
customer_id,
order_date,
total_amount,
SUM(total_amount) OVER (
PARTITION BY customer_id
ORDER BY order_date
) AS running_customer_total
FROM orders;
The key distinction from GROUP BY: window functions add calculations to each row without reducing the number of rows returned. GROUP BY collapses rows into groups. Use window functions when you need both detail and aggregate values in the same query.
ROW_NUMBER()
ROW_NUMBER()
ROW_NUMBER() assigns a unique sequential integer to each row within a partition. Rows are numbered starting from 1, and no two rows receive the same number even if they have tied values.
-- Number orders per customer by date
SELECT
order_id,
customer_id,
order_date,
total_amount,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY order_date
) AS order_sequence
FROM orders;
ROW_NUMBER is essential for:
- Removing duplicates (keeping the first or last)
- Paginating results
- Assigning unique identifiers within groups
-- Find the most recent order per customer (remove duplicates)
SELECT *
FROM (
SELECT
order_id,
customer_id,
order_date,
total_amount,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY order_date DESC
) AS rn
FROM orders
) ranked
WHERE rn = 1;
ROW_NUMBER is deterministic only when the ORDER BY uniquely identifies rows. If there are ties, the database may assign arbitrary numbers. To make it deterministic, add a unique column to the ORDER BY.
RANK() and DENSE_RANK()
RANK() and DENSE_RANK()
Both RANK() and DENSE_RANK() handle ties, but they differ in how they assign numbers after ties.
RANK() leaves gaps after ties. If two rows tie for rank 2, the next row gets rank 4 (skipping 3).
DENSE_RANK() has no gaps. If two rows tie for rank 2, the next row gets rank 3.
-- Compare RANK, DENSE_RANK, and ROW_NUMBER
SELECT
product_name,
category,
price,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) AS row_num,
RANK() OVER (PARTITION BY category ORDER BY price DESC) AS rank_val,
DENSE_RANK() OVER (PARTITION BY category ORDER BY price DESC) AS dense_rank_val
FROM products;
Example output:
| product_name | price | row_num | rank_val | dense_rank_val |
|---|---|---|---|---|
| Phone A | 999 | 1 | 1 | 1 |
| Phone B | 999 | 2 | 1 | 1 |
| Phone C | 799 | 3 | 3 | 2 |
-- Find top 3 products per category by price
SELECT *
FROM (
SELECT
product_name,
category,
price,
DENSE_RANK() OVER (
PARTITION BY category
ORDER BY price DESC
) AS price_rank
FROM products
) ranked
WHERE price_rank <= 3;
Use RANK when you want gaps after ties (standard competition ranking). Use DENSE_RANK when you want consecutive ranks (no gaps). Use ROW_NUMBER when you need unique numbers regardless of ties.
LAG() and LEAD()
LAG() and LEAD()
LAG() accesses a value from a previous row, and LEAD() accesses a value from a next row within the same partition. They are essential for comparing consecutive rows.
-- Compare each order to the previous order for the same customer
SELECT
order_id,
customer_id,
order_date,
total_amount,
LAG(total_amount, 1) OVER (
PARTITION BY customer_id
ORDER BY order_date
) AS prev_order_amount,
total_amount - LAG(total_amount, 1) OVER (
PARTITION BY customer_id
ORDER BY order_date
) AS amount_change
FROM orders;
Both functions accept three parameters:
- column: The column value to access
- offset (optional, default 1): How many rows back/forward to look
- default (optional): Value to return if no row exists at that position
-- LEAD: compare to the next order
SELECT
order_id,
customer_id,
order_date,
LEAD(order_date, 1) OVER (
PARTITION BY customer_id
ORDER BY order_date
) AS next_order_date
FROM orders;
-- With default value for first/last rows
SELECT
product_name,
price,
LAG(price, 1, 0) OVER (ORDER BY price) AS prev_price,
LEAD(price, 1, 0) OVER (ORDER BY price) AS next_price
FROM products;
LAG and LEAD are invaluable for time-series analysis, calculating differences between consecutive periods, and identifying gaps in data.
NTILE()
NTILE()
NTILE(n) divides the result set into approximately equal buckets (groups) and assigns a bucket number to each row. This is useful for creating percentiles, quartiles, or distributing data evenly.
-- Divide customers into 4 groups (quartiles) by total spending
SELECT
customer_id,
total_spent,
NTILE(4) OVER (ORDER BY total_spent DESC) AS spending_quartile
FROM (
SELECT customer_id, SUM(total_amount) AS total_spent
FROM orders
GROUP BY customer_id
) customer_totals;
Quartile 1 contains the top 25% of customers, quartile 4 contains the bottom 25%.
-- Divide products into 3 price tiers
SELECT
product_name,
price,
NTILE(3) OVER (ORDER BY price DESC) AS price_tier
FROM products;
NTILE guarantees that:
- Each bucket gets either floor(n/rows) or ceiling(n/rows) rows
- Buckets differ in size by at most 1 row
- Bucket numbers start at 1
-- Split employees into 5 salary bands for analysis
SELECT
employee_name,
salary,
NTILE(5) OVER (ORDER BY salary DESC) AS salary_band
FROM employees;
NTILE is commonly used in reporting to segment data into equal groups for comparison. It is also used in A/B testing to evenly distribute users across test groups.
Common Window Function Patterns
Common Window Function Patterns
Window functions enable several powerful analytical patterns that would be complex with traditional SQL.
Ranking top N per group:
-- Top 3 orders per customer
SELECT *
FROM (
SELECT *, ROW_NUMBER() OVER (
PARTITION BY customer_id ORDER BY total_amount DESC
) AS rn
FROM orders
) ranked
WHERE rn <= 3;
Running totals and cumulative calculations:
-- Running total per customer
SELECT
order_id,
customer_id,
order_date,
total_amount,
SUM(total_amount) OVER (
PARTITION BY customer_id ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM orders;
Period-over-period comparison:
-- Month-over-month revenue growth
SELECT
month,
revenue,
LAG(revenue) OVER (ORDER BY month) AS prev_month,
ROUND((revenue - LAG(revenue) OVER (ORDER BY month)) /
LAG(revenue) OVER (ORDER BY month) * 100, 2) AS growth_pct
FROM monthly_revenue;
Deduplication:
-- Remove duplicates, keeping the latest record
SELECT *
FROM (
SELECT *, ROW_NUMBER() OVER (
PARTITION BY customer_id ORDER BY updated_at DESC
) AS rn
FROM customers
) deduped
WHERE rn = 1;
Moving averages:
-- 7-day moving average of daily sales
SELECT
sale_date,
daily_sales,
AVG(daily_sales) OVER (
ORDER BY sale_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS moving_avg_7d
FROM daily_sales;
Window functions are one of the most powerful features in SQL. Mastering them enables complex analytics in a single query without self-joins or correlated subqueries.
Practice Problems
Write a query to rank all products by price within each category. Use RANK() so ties get the same rank. Return product_name, category, price, and price_rank.
Solution
SELECT
product_name,
category,
price,
RANK() OVER (
PARTITION BY category
ORDER BY price DESC
) AS price_rank
FROM products;Write a query to assign a sequence number to each order per customer, ordered by order_date. Return order_id, customer_id, order_date, and order_number.
Solution
SELECT
order_id,
customer_id,
order_date,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY order_date
) AS order_number
FROM orders;Write a query to show each order along with the total_amount from the previous order for the same customer. Calculate the difference. Return order_id, customer_id, total_amount, prev_amount, and difference.
Solution
SELECT
order_id,
customer_id,
total_amount,
LAG(total_amount) OVER (
PARTITION BY customer_id
ORDER BY order_date
) AS prev_amount,
total_amount - LAG(total_amount) OVER (
PARTITION BY customer_id
ORDER BY order_date
) AS difference
FROM orders;Write a query to divide customers into 4 spending quartiles based on their total spending. Return customer_name, total_spent, and quartile.
Solution
WITH customer_totals AS (
SELECT customer_id, SUM(total_amount) AS total_spent
FROM orders
GROUP BY customer_id
)
SELECT
c.customer_name,
ct.total_spent,
NTILE(4) OVER (
ORDER BY ct.total_spent DESC
) AS quartile
FROM customers c
JOIN customer_totals ct ON c.customer_id = ct.customer_id;Write a query to find the most expensive product in each category using ROW_NUMBER(). Return product_name, category, and price.
Solution
SELECT product_name, category, price
FROM (
SELECT
product_name,
category,
price,
ROW_NUMBER() OVER (
PARTITION BY category
ORDER BY price DESC
) AS rn
FROM products
) ranked
WHERE rn = 1;Write a query to create a leaderboard of customers by total spending using DENSE_RANK(). Two customers with the same spending should get the same rank, and the next rank should be consecutive. Return customer_name, total_spent, and rank.
Solution
WITH customer_totals AS (
SELECT customer_id, SUM(total_amount) AS total_spent
FROM orders
GROUP BY customer_id
)
SELECT
c.customer_name,
ct.total_spent,
DENSE_RANK() OVER (
ORDER BY ct.total_spent DESC
) AS rank
FROM customers c
JOIN customer_totals ct ON c.customer_id = ct.customer_id;Quiz
1. What is the difference between RANK() and DENSE_RANK()?
2. How does ROW_NUMBER() handle ties?
3. What does LAG(column, 2) do?
4. What does NTILE(4) do?
5. Why do window functions not collapse rows like GROUP BY?
Flashcards
Question
What is a window function?
Click to reveal answer
Answer
A function that performs calculations across a set of rows related to the current row, without collapsing them. Uses the OVER clause to define the window.
Question
ROW_NUMBER vs RANK vs DENSE_RANK
Click to reveal answer
Answer
ROW_NUMBER: unique numbers, no ties. RANK: same rank for ties, gaps after. DENSE_RANK: same rank for ties, no gaps.
Question
What do LAG and LEAD do?
Click to reveal answer
Answer
LAG accesses a value from a previous row. LEAD accesses a value from a next row. Both within the same partition, specified by offset.
Question
When to use NTILE()?
Click to reveal answer
Answer
When you need to divide results into equal groups like quartiles, percentiles, or salary bands. NTILE(n) assigns bucket numbers 1 to n.
Question
How is PARTITION BY different from GROUP BY?
Click to reveal answer
Answer
PARTITION BY divides rows into groups for window function calculations without collapsing rows. GROUP BY collapses rows into one row per group.
Revision Notes
Key Takeaways
- 1.Window functions add calculations without collapsing rows
- 2.ROW_NUMBER gives unique numbers; RANK and DENSE_RANK handle ties differently
- 3.LAG and LEAD access previous/next row values for period comparisons
- 4.NTILE divides results into equal buckets for percentiles and quartiles
Interview Tips
- •Explain the difference between ROW_NUMBER, RANK, and DENSE_RANK with examples
- •Show how to find the top N per group using ROW_NUMBER
- •Discuss when to use LAG vs self-joins for period comparisons
- •Mention that window functions are more efficient than correlated subqueries for analytics
Cheat Sheet
Window Functions Cheat Sheet
OVER Clause
AGG() OVER (PARTITION BY col ORDER BY col)
Ranking
ROW_NUMBER() OVER (...) -- unique numbers, no ties
RANK() OVER (...) -- ties get same rank, gaps after
DENSE_RANK() OVER (...) -- ties get same rank, no gaps
Navigation
LAG(col, offset, default) OVER (...) -- previous row
LEAD(col, offset, default) OVER (...) -- next row
Bucketing
NTILE(n) OVER (...) -- divide into n buckets
Key Points
- Window functions do NOT collapse rows
- OVER clause defines the window
- PARTITION BY divides, ORDER BY orders within partition
- Can combine with CTEs and subqueries