SQL window functions (OVER, PARTITION BY)
A GROUP BY reduces the rows: one row per group. But how do you show each product with its rank in its category, or each month with the revenue accumulated since January? Window functions compute over a set of rows without merging them: each row keeps its identity.
Syntax
FUNCTION() OVER (
PARTITION BY group_column
ORDER BY sort_column
)How it works
The OVER (…) clause defines the “window”, that is, the rows the function looks at:
PARTITION BYsplits the rows into groups, like a GROUP BY, but without merging them.ORDER BYsets the order of the rows within each group.
Ranking functions
| Function | With ties at 100, 100, 90 |
|---|---|
ROW_NUMBER() | 1, 2, 3 (numbering without ties) |
RANK() | 1, 1, 3 (same ranks, then a gap) |
DENSE_RANK() | 1, 1, 2 (same ranks, no gap) |
Window aggregates
SUM(…) OVER (ORDER BY …) gives a running total. AVG(…) OVER (PARTITION BY …) shows the group average next to each row. LAG() and LEAD() read the value of the previous or next row.
Window functions are computed after the WHERE and the GROUP BY. To filter on their result (for example “the top 3”), you have to go through a subquery or a CTE.
Diagram
| categorie | nom | prix |
|---|---|---|
| Sport | Raquette | 45 |
| Sport | Gourde | 12 |
| Maison | Lampe | 39 |
| Maison | Plaid | 25 |
| categorie | nom | rang |
|---|---|---|
| Maison | Lampe | 1 |
| Maison | Plaid | 2 |
| Sport | Raquette | 1 |
| Sport | Gourde | 2 |
Simplified diagram to show the idea. The examples below use the real SQL Quest practice database (a fictional online shop: see the full schema).
Glossary: the sample database uses French names
clients- customers
commandes- orders
lignes_commande- order lines
produits- products
fournisseurs- suppliers
employes- employees
prenom / nom- first name / last name (or name)
ville- city
pays- country
prix- price
prix_unitaire- unit price
quantite- quantity
categorie- category
date_commande- order date
poste- job title
departement- department
salaire- salary
statut- status: livrée = delivered, expédiée = shipped, en cours = in progress, annulée = cancelled
Examples with their results
1. Rank within each group
The rank of each product in its category, from most to least expensive.
SELECT categorie, nom, prix,
RANK() OVER (PARTITION BY categorie ORDER BY prix DESC) AS rang
FROM produits
LIMIT 10; Try it in the sandbox| categorie | nom | prix | rang |
|---|---|---|---|
| Alimentation | Huile d'olive | 40.06 | 1 |
| Alimentation | Thé vert | 35.83 | 2 |
| Alimentation | Chocolat noir | 22.31 | 3 |
| Alimentation | Café en grains | 21.79 | 4 |
| Alimentation | Miel bio | 3.7 | 5 |
| Informatique | Écran 27" | 342 | 1 |
| Informatique | SSD 1To | 317 | 2 |
| Informatique | Souris sans fil | 103 | 3 |
| Informatique | Webcam HD | 101 | 4 |
| Informatique | Casque gaming | 98 | 5 |
10 rows in total
2. The top 2 per group
The two best-selling products (by quantity) of each category: rank them in a CTE, then filter.
WITH ventes AS (
SELECT p.categorie, p.nom, SUM(l.quantite) AS vendus,
ROW_NUMBER() OVER (PARTITION BY p.categorie ORDER BY SUM(l.quantite) DESC) AS rn
FROM produits p JOIN lignes_commande l ON l.produit_id = p.id
GROUP BY p.id
)
SELECT categorie, nom, vendus
FROM ventes
WHERE rn <= 2; Try it in the sandbox| categorie | nom | vendus |
|---|---|---|
| Alimentation | Thé vert | 179 |
| Alimentation | Huile d'olive | 167 |
| Informatique | Webcam HD | 175 |
| Informatique | Souris sans fil | 169 |
| Livres | Roman policier | 198 |
| Livres | Livre de cuisine | 147 |
| Maison | Cafetière | 178 |
| Maison | Plaid | 145 |
| Sport | Raquette | 169 |
| Sport | Corde à sauter | 165 |
10 rows in total
3. A running total
The revenue of each month of 2025 and the running total since January.
WITH mois AS (
SELECT strftime('%Y-%m', o.date_commande) AS mois, SUM(l.quantite * l.prix_unitaire) AS ca
FROM commandes o JOIN lignes_commande l ON l.commande_id = o.id
WHERE o.date_commande LIKE '2025%'
GROUP BY mois
)
SELECT mois, ROUND(ca, 2) AS ca, ROUND(SUM(ca) OVER (ORDER BY mois), 2) AS cumul
FROM mois
LIMIT 6; Try it in the sandbox| mois | ca | cumul |
|---|---|---|
| 2025-01 | 6,083.58 | 6,083.58 |
| 2025-02 | 6,265.52 | 12,349.1 |
| 2025-03 | 4,508.37 | 16,857.47 |
| 2025-04 | 4,131.73 | 20,989.2 |
| 2025-05 | 3,910.96 | 24,900.16 |
| 2025-06 | 7,296.54 | 32,196.7 |
6 rows in total
4. Compare with the group average
Each employee, their salary and the average of their department, on the same row.
SELECT departement, nom, salaire,
ROUND(AVG(salaire) OVER (PARTITION BY departement)) AS moyenne_dept
FROM employes
LIMIT 8; Try it in the sandbox| departement | nom | salaire | moyenne_dept |
|---|---|---|---|
| Direction | Dubois | 9,500 | 9,500 |
| IT | Roux | 6,800 | 4,760 |
| IT | Blanc | 3,800 | 4,760 |
| IT | Faure | 3,600 | 4,760 |
| IT | Moreau | 4,500 | 4,760 |
| IT | Garcia | 5,100 | 4,760 |
| RH | Simon | 4,800 | 4,233 |
| RH | Bonnet | 3,300 | 4,233 |
8 rows in total
Common mistakes
Filtering a window function in the WHERE
WHERE is evaluated before window functions: SQLite answers misuse of window function. Go through a CTE or a subquery.
SELECT nom, RANK() OVER (ORDER BY prix DESC) AS r FROM produits WHERE RANK() OVER (ORDER BY prix DESC) <= 3;SELECT * FROM (SELECT nom, RANK() OVER (ORDER BY prix DESC) AS r FROM produits) WHERE r <= 3;Forgetting PARTITION BY
Without PARTITION BY, the ranking covers the whole table instead of starting again in each category.
SELECT categorie, nom, RANK() OVER (ORDER BY prix DESC) FROM produits;SELECT categorie, nom, RANK() OVER (PARTITION BY categorie ORDER BY prix DESC) FROM produits;Confusing RANK and ROW_NUMBER for a top N
With ties, RANK can return more than N rows per group, and ROW_NUMBER cuts them arbitrarily. Choose based on what you want to do with ties.
SELECT * FROM (SELECT nom, RANK() OVER (ORDER BY stock) AS r FROM produits) WHERE r <= 1;SELECT * FROM (SELECT nom, ROW_NUMBER() OVER (ORDER BY stock, id) AS r FROM produits) WHERE r <= 1;Exercises
Try writing the query yourself before opening the solution.
Challenge 1. Number each customer's orders from oldest to newest (client_id, id, date, number).
Show a solution
SELECT client_id, id, date_commande, ROW_NUMBER() OVER (PARTITION BY client_id ORDER BY date_commande) AS num FROM commandes LIMIT 10;| client_id | id | date_commande | num |
|---|---|---|---|
| 1 | 19 | 2023-03-07 | 1 |
| 1 | 280 | 2023-05-27 | 2 |
| 1 | 48 | 2024-11-07 | 3 |
| 1 | 331 | 2025-04-06 | 4 |
| 1 | 426 | 2025-09-25 | 5 |
| 1 | 408 | 2025-09-26 | 6 |
| 2 | 520 | 2023-02-05 | 1 |
| 2 | 369 | 2023-08-07 | 2 |
10 rows in total · 8 shown
Try it in the sandboxChallenge 2. Show the best-paid employee of each department.
Show a solution
SELECT departement, prenom, nom, salaire FROM (SELECT *, RANK() OVER (PARTITION BY departement ORDER BY salaire DESC) AS r FROM employes) WHERE r = 1;| departement | prenom | nom | salaire |
|---|---|---|---|
| Direction | Chloé | Dubois | 9,500 |
| IT | Nathan | Roux | 6,800 |
| RH | Louis | Chevalier | 5,300 |
| Ventes | Jade | Lefebvre | 6,200 |
4 rows in total
Try it in the sandboxThe interactive exercises are checked automatically, with a tutor who explains your mistakes. A short lesson stays next to the editor.
Frequently asked questions
What is the difference between GROUP BY and PARTITION BY?
GROUP BY merges the rows of each group into one. PARTITION BY builds the same groups, but keeps every row and adds the result of the calculation to each one.
What is the difference between RANK, DENSE_RANK and ROW_NUMBER?
ROW_NUMBER numbers without ties (1, 2, 3). RANK gives tied rows the same rank, then skips (1, 1, 3). DENSE_RANK gives the same rank without skipping (1, 1, 2).
Does SQLite support window functions?
Yes, since version 3.25 (2018). PostgreSQL, MySQL 8+, SQL Server and Oracle support them too.