Going Deeper: Professional SQLPro· 40 min read

Window Functions: Ranking & Running Totals

Add ranks, row numbers and running totals to your rows without collapsing them — the analytics feature that turns SQL into a reporting powerhouse.

What you will learn

  • Explain how a window function differs from GROUP BY
  • Rank rows with ROWNUMBER, RANK and DENSERANK using OVER and PARTITION BY
  • Build running totals and compare rows with SUM OVER and LAG/LEAD

Calculations that keep every row

A GROUP BY aggregate collapses many rows into one summary row — you lose the individual rows. A window function does something different and powerful: it performs a calculation across a set of related rows but keeps every original row, simply adding the computed value as an extra column. So you can show each order and its rank, or each sale and the running total so far — side by side. This is the signature “beginner-to-expert” feature of modern MySQL 8.

The key piece of syntax is OVER (...). It defines the window — the set of rows the function looks at. Inside OVER you can add PARTITION BY (split rows into groups, like GROUP BY but without collapsing) and ORDER BY (order the rows within each group, which ranking and running totals need).

We will use this sales table throughout:

idregionrepamount
1NorthAsha500
2NorthRavi900
3NorthMeera500
4SouthKabir300
5SouthPriya700

ROWNUMBER, RANK and DENSERANK

The most common window functions number or rank rows. To rank sales reps within each region from highest amount to lowest, you partition by region and order by amount:

Three ways to number rows within each region
SELECT
  region,
  rep,
  amount,
  ROW_NUMBER() OVER (PARTITION BY region ORDER BY amount DESC) AS row_num,
  RANK()       OVER (PARTITION BY region ORDER BY amount DESC) AS rnk,
  DENSE_RANK() OVER (PARTITION BY region ORDER BY amount DESC) AS dense_rnk
FROM sales;

Reading the OVER clause: PARTITION BY region restarts the numbering for each region (North’s reps are ranked separately from South’s), and ORDER BY amount DESC ranks the highest amount as number 1. The three functions differ only in how they handle ties (two equal amounts): ROW_NUMBER always gives a unique number even to ties (1, 2, 3); RANK gives ties the same number but then skips the next (1, 1, 3); DENSE_RANK gives ties the same number and does not skip (1, 1, 2).

Note: Output: region rep amount rownum rnk densernk North Ravi 900 1 1 1 North Asha 500 2 2 2 North Meera 500 3 2 2 South Priya 700 1 1 1 South Kabir 300 2 2 2 Look at North: Asha and Meera both have 500 (a tie). ROW_NUMBER still gives them 2 and 3; RANK gives both 2 then would jump to 4 for a next row; DENSE_RANK gives both 2 and the next would be 3. Every original row is kept — nothing collapsed.

A real use: top N per group

Window ranking unlocks a question that is awkward without it: “the top 2 sales reps in each region”. You compute the rank in an inner query, then keep only ranks 1 and 2 in the outer query (you cannot filter on a window function directly in WHERE, so you wrap it):

Top 2 reps in every region
SELECT region, rep, amount
FROM (
  SELECT region, rep, amount,
    ROW_NUMBER() OVER (PARTITION BY region ORDER BY amount DESC) AS rn
  FROM sales
) AS ranked
WHERE rn <= 2;

The inner query (a derived table, which you met in the subqueries lesson) adds the rn ranking column. The outer query then keeps only rows where rn is 1 or 2 — the top two per region. This “rank then filter” pattern is one of the most common real-world uses of window functions.

Note: Output: region rep amount North Ravi 900 North Asha 500 South Priya 700 South Kabir 300 Two reps from each region, the highest earners first. Because the ranking was partitioned by region, North and South were ranked independently.

Running totals with SUM() OVER

Add ORDER BY inside OVER to an aggregate like SUM and it becomes a running (cumulative) total — each row shows the sum of itself and all rows before it. This is how dashboards draw “total sales to date” lines.

A running total that grows row by row
SELECT
  id,
  amount,
  SUM(amount) OVER (ORDER BY id) AS running_total
FROM sales;

SUM(amount) OVER (ORDER BY id) tells MySQL: for each row, add up amount from the first row up to this row, in id order. Row 1 shows 500; row 2 shows 500 + 900 = 1400; row 3 adds another 500 to reach 1900, and so on. Without ORDER BY inside OVER, SUM(amount) OVER () would instead show the grand total on every row.

Note: Output: id amount running_total 1 500 500 2 900 1400 3 500 1900 4 300 2200 5 700 2900 Each row carries the cumulative sum up to that point — 2900 is the grand total on the final row. Add PARTITION BY region to restart the running total for each region.

LAG and LEAD — compare to the previous or next row

LAG() pulls a value from the previous row and LEAD() from the next row, in the window’s order. This makes row-to-row comparisons — “how much more than the previous sale?” — trivial:

Compare each sale to the one before it
SELECT
  id,
  amount,
  LAG(amount) OVER (ORDER BY id)  AS prev_amount,
  amount - LAG(amount) OVER (ORDER BY id) AS change
FROM sales;

LAG(amount) OVER (ORDER BY id) fetches the amount from the row one position earlier. Subtracting it from the current amount gives the change since the last sale. The very first row has no previous row, so LAG returns NULL there (and the change is NULL too).

Note: Output: id amount prev_amount change 1 500 NULL NULL 2 900 500 400 3 500 900 -400 4 300 500 -200 5 700 300 400 Row 2 sold 400 more than row 1; row 3 sold 400 less than row 2, and so on. LAG/LEAD are how you build “vs. previous month” and trend columns in reports.

Tip: Remember the one-line difference: GROUP BY collapses rows into summaries; a window function with OVER (...) keeps every row and adds the calculation alongside. Reach for windows whenever you need a rank, a running total, a percentage-of-total, or a comparison to a neighbouring row.

Q. What is the main difference between a window function and a GROUP BY aggregate?

Answer: A window function (using OVER) computes across related rows but returns every original row with an extra column, whereas GROUP BY collapses each group into a single summary row. That is why windows can show each row alongside its rank or running total.

✍️ Practice

  1. Use ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...) to rank rows within groups, then filter the outer query for the top 1 per group.
  2. Add a SUM(amount) OVER (ORDER BY id) running-total column to a table of your own.

🏠 Homework

  1. Build a query that ranks products by price within each category and keeps only the top 3 per category.
Want to learn this with a mentor?

CodingClave runs guided, project-based training (28-day, 45-day & 6-month batches).

Explore Training →