SQL Quest
Sign in Français 0 XP Lvl 1
Advanced

SQL window functions (OVER, PARTITION BY)

5 min read · 4 runnable examples · updated October 9, 2026

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 BY splits the rows into groups, like a GROUP BY, but without merging them.
  • ORDER BY sets the order of the rows within each group.

Ranking functions

FunctionWith 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

produits
categorienomprix
SportRaquette45
SportGourde12
MaisonLampe39
MaisonPlaid25
result
categorienomrang
MaisonLampe1
MaisonPlaid2
SportRaquette1
SportGourde2
RANK() OVER (PARTITION BY categorie ORDER BY prix DESC): the ranking starts again at 1 in each category, and every row is kept.

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
Result
categorienomprixrang
AlimentationHuile d'olive40.061
AlimentationThé vert35.832
AlimentationChocolat noir22.313
AlimentationCafé en grains21.794
AlimentationMiel bio3.75
InformatiqueÉcran 27"3421
InformatiqueSSD 1To3172
InformatiqueSouris sans fil1033
InformatiqueWebcam HD1014
InformatiqueCasque gaming985

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
Result
categorienomvendus
AlimentationThé vert179
AlimentationHuile d'olive167
InformatiqueWebcam HD175
InformatiqueSouris sans fil169
LivresRoman policier198
LivresLivre de cuisine147
MaisonCafetière178
MaisonPlaid145
SportRaquette169
SportCorde à sauter165

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
Result
moiscacumul
2025-016,083.586,083.58
2025-026,265.5212,349.1
2025-034,508.3716,857.47
2025-044,131.7320,989.2
2025-053,910.9624,900.16
2025-067,296.5432,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
Result
departementnomsalairemoyenne_dept
DirectionDubois9,5009,500
ITRoux6,8004,760
ITBlanc3,8004,760
ITFaure3,6004,760
ITMoreau4,5004,760
ITGarcia5,1004,760
RHSimon4,8004,233
RHBonnet3,3004,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.

Avoid
SELECT nom, RANK() OVER (ORDER BY prix DESC) AS r FROM produits WHERE RANK() OVER (ORDER BY prix DESC) <= 3;
Correct
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.

Avoid
SELECT categorie, nom, RANK() OVER (ORDER BY prix DESC) FROM produits;
Correct
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.

Avoid
SELECT * FROM (SELECT nom, RANK() OVER (ORDER BY stock) AS r FROM produits) WHERE r <= 1;
Correct
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_ididdate_commandenum
1192023-03-071
12802023-05-272
1482024-11-073
13312025-04-064
14262025-09-255
14082025-09-266
25202023-02-051
23692023-08-072

10 rows in total · 8 shown

Try it in the sandbox

Challenge 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;
departementprenomnomsalaire
DirectionChloéDubois9,500
ITNathanRoux6,800
RHLouisChevalier5,300
VentesJadeLefebvre6,200

4 rows in total

Try it in the sandbox
Want more practice?

The interactive exercises are checked automatically, with a tutor who explains your mistakes. A short lesson stays next to the editor.

Do the interactive exercises

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.

Read next