ShareYourGroupShareYourGroupSQL

Lesson 26 of 26 · see the practice database

Window functions

GROUP BY squeezes each group into one row. You get the total, and you lose the rows. A window function works out a figure across a group of rows and keeps every row. With one, you can show each employee's salary beside the average of their department, number the rows of each group, or keep a running total. Problems that need awkward subqueries become one line.

Window functions are the last big idea in this course, and the one that most separates a comfortable SQL user from a beginner. All the major databases support them: PostgreSQL, SQL Server and Oracle for many years, MySQL from version 8, SQLite from version 3.25.

OVER makes a function a window function

Compare these. First, GROUP BY: one row per department.

SELECT department, ROUND(AVG(salary)) AS avg_salary
FROM employees
GROUP BY department;

Result

departmentavg_salary
Management95000.0
Sales52000.0
Support34500.0
Warehouse36000.0

4 rows

Now the window version: every employee, with the department's average alongside.

SELECT name, department, salary,
       ROUND(AVG(salary) OVER (PARTITION BY department)) AS dept_average
FROM employees
ORDER BY department, salary DESC;

Result

namedepartmentsalarydept_average
Anita KapoorManagement9500095000.0
Rohit BansalSales6000052000.0
Deepa IyerSales4800052000.0
Manoj YadavSales4800052000.0
Harpreet KaurSupport3600034500.0
Nikhil JainSupport3300034500.0
Farah SheikhWarehouse4200036000.0
Gopal ReddyWarehouse3000036000.0

8 rows

The word OVER is what changes an aggregate into a window function. Inside its brackets, PARTITION BY department says "work this out separately for each department". A partition is like a group, except that the rows are not merged.

With that, comparing a row with its group is plain arithmetic.

SELECT name, department, salary,
       salary - ROUND(AVG(salary) OVER (PARTITION BY department)) AS above_or_below
FROM employees
ORDER BY department, salary DESC;

Result

namedepartmentsalaryabove_or_below
Anita KapoorManagement950000.0
Rohit BansalSales600008000.0
Deepa IyerSales48000-4000.0
Manoj YadavSales48000-4000.0
Harpreet KaurSupport360001500.0
Nikhil JainSupport33000-1500.0
Farah SheikhWarehouse420006000.0
Gopal ReddyWarehouse30000-6000.0

8 rows

An empty OVER: the whole result

Leave the brackets empty and the window is all the rows.

SELECT name, price,
       SUM(price) OVER () AS total_of_all,
       ROUND(100.0 * price / SUM(price) OVER (), 1) AS percent
FROM products
ORDER BY price DESC
LIMIT 5;

Result

namepricetotal_of_allpercent
Pressure Cooker 3 L1450771018.8
Power Bank1299771016.8
Bedsheet Double899771011.7
School Bag75077109.7
Basmati Rice 5 kg65077108.4

5 rows

In the lesson on subqueries this needed a subquery for the total. Here it does not.

Numbering rows: ROW_NUMBER

ROW_NUMBER() numbers the rows 1, 2, 3 and so on. It needs to know the order to number them in, so an ORDER BY goes inside the OVER.

SELECT ROW_NUMBER() OVER (ORDER BY price DESC) AS position, name, price
FROM products
LIMIT 5;

Result

positionnameprice
1Pressure Cooker 3 L1450
2Power Bank1299
3Bedsheet Double899
4School Bag750
5Basmati Rice 5 kg650

5 rows

Add PARTITION BY and the numbering starts again at 1 for each group.

SELECT category, name, price,
       ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) AS position
FROM products
ORDER BY category, position;

Result

categorynamepriceposition
ElectronicsPower Bank12991
ElectronicsEarphones4992
ElectronicsExtension Board3993
ElectronicsLED Bulb 9 W1104
GroceryBasmati Rice 5 kg6501
GroceryTea 500 g2802
GroceryToor Dal 1 kg1603
GrocerySunflower Oil 1 L1454
HomePressure Cooker 3 L14501
HomeBedsheet Double8992
HomeSteel Bottle 1 L4503
HomeWall Clock3494

16 rows

Showing the first 12 of 16 rows.

Top N in each group

"The two dearest products in each category" is one of the most asked questions in SQL, in work and in interviews. LIMIT cannot do it, because it cuts the whole result, not each group. Number the rows inside each group, then keep the low numbers.

There is one catch: a window function cannot be used in WHERE, because WHERE runs before window functions are worked out. So the numbering goes in a WITH step, and the filter in the main query.

WITH ranked AS (
  SELECT category, name, price,
         ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) AS position
  FROM products
)
SELECT category, name, price
FROM ranked
WHERE position <= 2
ORDER BY category, price DESC;

Result

categorynameprice
ElectronicsPower Bank1299
ElectronicsEarphones499
GroceryBasmati Rice 5 kg650
GroceryTea 500 g280
HomePressure Cooker 3 L1450
HomeBedsheet Double899
StationerySchool Bag750
StationeryGeometry Box120

8 rows

The same pattern gives each customer's most recent order.

WITH numbered AS (
  SELECT o.*, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) AS rn
  FROM orders o
)
SELECT c.name, n.id AS order_id, n.order_date, n.status
FROM numbered n
JOIN customers c ON c.id = n.customer_id
WHERE n.rn = 1
ORDER BY c.name;

Result

nameorder_idorder_datestatus
Asha Verma202026-10-04pending
Imran Ali152026-06-21delivered
Joy Thomas142026-06-02delivered
Kabir Khan182026-09-01pending
Neha Joshi192026-09-17pending
Priya Nair172026-08-11shipped
Ravi Kumar162026-07-09delivered
Sunita Rao132026-05-19shipped
Vikram Das112026-04-18delivered

9 rows

Keep this pattern. You will use it for the rest of your working life.

Ranking with ties: RANK and DENSE_RANK

ROW_NUMBER gives every row a different number, even when values tie. Two functions respect ties.

SELECT name, salary,
       ROW_NUMBER() OVER (ORDER BY salary DESC) AS row_number,
       RANK()       OVER (ORDER BY salary DESC) AS rank,
       DENSE_RANK() OVER (ORDER BY salary DESC) AS dense_rank
FROM employees;

Result

namesalaryrow_numberrankdense_rank
Anita Kapoor95000111
Rohit Bansal60000222
Deepa Iyer48000333
Manoj Yadav48000433
Farah Sheikh42000554
Harpreet Kaur36000665
Nikhil Jain33000776
Gopal Reddy30000887

8 rows

Look at the two people on the same salary.

FunctionTied rowsAfter a tie
ROW_NUMBERget different numbers, in no particular ordercarries on
RANKshare a numberskips: 1, 2, 3, 3, 5
DENSE_RANKshare a numberdoes not skip: 1, 2, 3, 3, 4

RANK works like a sports table: two people share third place and the next is fifth. DENSE_RANK leaves no gaps.

With DENSE_RANK, the "second highest salary" question has a clean answer that also handles ties and extends to third, fourth or any place.

WITH r AS (
  SELECT name, salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS place
  FROM employees
)
SELECT name, salary FROM r WHERE place = 2;

Result

namesalary
Rohit Bansal60000

1 row

Running totals

Put an ORDER BY inside the OVER of a SUM, and the sum becomes cumulative: each row shows the total of everything up to and including itself.

WITH totals AS (
  SELECT o.id, o.order_date, SUM(oi.quantity * p.price) AS total
  FROM orders o
  JOIN order_items oi ON oi.order_id = o.id
  JOIN products p     ON oi.product_id = p.id
  WHERE o.status = 'delivered'
  GROUP BY o.id, o.order_date
)
SELECT id, order_date, total,
       SUM(total) OVER (ORDER BY order_date, id) AS running_total
FROM totals
ORDER BY order_date, id;

Result

idorder_datetotalrunning_total
12026-01-0517001700
22026-01-128392539
42026-02-025703109
52026-02-149904099
62026-02-2814505549
72026-03-039006449
82026-03-157197168
102026-04-0110508218
112026-04-1811159333
122026-05-06179811131
142026-06-02145812589
152026-06-2169013279

13 rows

Showing the first 12 of 13 rows.

Add PARTITION BY for a running total that restarts for each customer, each month or each account. This is how a bank statement shows a balance after every line.

A moving average smooths out ups and downs. The extra words say which rows around the current one to include: here, this row and the two before it.

WITH totals AS (
  SELECT o.id, o.order_date, SUM(oi.quantity * p.price) AS total
  FROM orders o
  JOIN order_items oi ON oi.order_id = o.id
  JOIN products p     ON oi.product_id = p.id
  WHERE o.status = 'delivered'
  GROUP BY o.id, o.order_date
)
SELECT id, order_date, total,
       ROUND(AVG(total) OVER (ORDER BY order_date, id ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)) AS average_of_last_3
FROM totals
ORDER BY order_date, id
LIMIT 8;

Result

idorder_datetotalaverage_of_last_3
12026-01-0517001700.0
22026-01-128391270.0
42026-02-025701036.0
52026-02-14990800.0
62026-02-2814501003.0
72026-03-039001113.0
82026-03-157191023.0
102026-04-011050890.0

8 rows

Looking at the row before or after: LAG and LEAD

LAG fetches a value from the previous row. LEAD fetches from the next. They answer "how does this compare with last time?".

WITH monthly AS (
  SELECT strftime('%Y-%m', order_date) AS month, COUNT(*) AS orders
  FROM orders
  GROUP BY strftime('%Y-%m', order_date)
)
SELECT month, orders,
       LAG(orders) OVER (ORDER BY month) AS previous_month,
       orders - LAG(orders) OVER (ORDER BY month) AS change
FROM monthly
ORDER BY month;

Result

monthordersprevious_monthchange
2026-013NULLNULL
2026-02330
2026-03330
2026-0423-1
2026-05220
2026-06220
2026-0712-1
2026-08110
2026-09211
2026-1012-1

10 rows

The first row has no previous row, so LAG gives NULL there.

How many days passed between each customer's orders?

SELECT customer_id, order_date,
       LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) AS previous_order,
       CAST(julianday(order_date)
            - julianday(LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date)) AS INTEGER) AS days_since
FROM orders
WHERE customer_id IN (1, 2)
ORDER BY customer_id, order_date;

Result

customer_idorder_dateprevious_orderdays_since
12026-01-05NULLNULL
12026-02-022026-01-0528
12026-05-062026-02-0293
12026-10-042026-05-06151
22026-01-12NULLNULL
22026-03-032026-01-1250
22026-07-092026-03-03128

7 rows

The parts of OVER

function() OVER (
  PARTITION BY columns     -- split the rows into groups (optional)
  ORDER BY columns         -- the order inside each group (needed for numbering, running totals, LAG)
  ROWS BETWEEN ... AND ... -- which neighbouring rows to include (optional)
)

GROUP BY or a window function?

GROUP BYWindow function
Rows in the resultone per groupevery original row
Can show row detailsnoyes
Typical question"total per category""each product and its share of its category"

They can be combined. A query may group first and then apply window functions to the grouped rows, as the running total above does.

Common mistakes

Try it yourself

Exercise 1. Show each product's name, category and price, together with the highest price in its category as category_max. Sort by product id.

Solve it in the practice box

Show one answer
SELECT name, category, price, MAX(price) OVER (PARTITION BY category) AS category_max FROM products ORDER BY id;
namecategorypricecategory_max
Basmati Rice 5 kgGrocery650650
Toor Dal 1 kgGrocery160650
Sunflower Oil 1 LGrocery145650
Tea 500 gGrocery280650
Notebook 200 pagesStationery60750
Ball Pen pack of 10Stationery90750
Geometry BoxStationery120750
School BagStationery750750
LED Bulb 9 WElectronics1101299
Extension BoardElectronics3991299
EarphonesElectronics4991299
Power BankElectronics12991299

16 rows

Showing the first 12 of 16 rows.

Exercise 2. Rank the products by stock, highest first, using RANK(). Show the name, the stock and the rank, sorted by rank and then name.

Solve it in the practice box

Show one answer
SELECT name, stock, RANK() OVER (ORDER BY stock DESC) AS r FROM products ORDER BY r, name;
namestockr
Notebook 200 pages3001
LED Bulb 9 W2002
Ball Pen pack of 101503
Toor Dal 1 kg754
Sunflower Oil 1 L605
Steel Bottle 1 L556
Geometry Box457
Basmati Rice 5 kg408
Extension Board359
Wall Clock3010
Bedsheet Double2511
School Bag2012

16 rows

Showing the first 12 of 16 rows.

Exercise 3. Show the name, department and salary of the highest-paid employee in each department, sorted by department. Use ROW_NUMBER() in a WITH step.

Solve it in the practice box

Show one answer
WITH x AS (SELECT name, department, salary, ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rn FROM employees) SELECT name, department, salary FROM x WHERE rn = 1 ORDER BY department;
namedepartmentsalary
Anita KapoorManagement95000
Rohit BansalSales60000
Harpreet KaurSupport36000
Farah SheikhWarehouse42000

4 rows

Examples on this page were run with SQLite 3.50.4 on 12 November 2026. The results shown are the real results.

This is an independent learning site. It is not connected with the makers of any database product. MySQL, PostgreSQL, SQL Server, Oracle and SQLite are named only so that you know where a piece of SQL works. The shop, its customers and its staff in the practice database are made up.