Your manager sends a one-line request on a Thursday afternoon: "Can you pull the top three orders for every customer, plus how each one compares to the previous order?" You open your SQL editor, stare at the table, and feel the familiar dread. A plain GROUP BY collapses rows into one per customer, so you lose the very detail you need. The usual escape is a messy self-join or a pile of subqueries that nobody, including future you, wants to read.

There is a cleaner tool, and it has been in MySQL since version 8.0: window functions. They let you calculate rankings, running totals and row-to-row comparisons without collapsing your result set. Once the idea clicks, a whole category of "I need a join for this" problems quietly disappears.

A window function looks at neighboring rows without throwing them away. That single idea replaces most self-joins you will ever write.

The Core Idea: OVER() Keeps Your Rows

A normal aggregate like SUM(amount) with GROUP BY customer_id returns one row per customer. A window function writes OVER (...) after the function, and every original row survives; the function simply adds a new column computed across a "window" of related rows.

Here is the sample table we will use throughout. All values are made up.

CREATE TABLE orders (
  id          INT PRIMARY KEY AUTO_INCREMENT,
  customer_id INT NOT NULL,
  order_date  DATE NOT NULL,
  amount      DECIMAL(10,2) NOT NULL
);

INSERT INTO orders (customer_id, order_date, amount) VALUES
  (1, '2026-01-05', 120.00),
  (1, '2026-02-11',  80.00),
  (1, '2026-03-02', 200.00),
  (2, '2026-01-20',  50.00),
  (2, '2026-02-25',  75.00),
  (3, '2026-03-15', 300.00);

Compare the two approaches. The first collapses; the second keeps every order and attaches the customer total next to it:

-- Collapses to one row per customer
SELECT customer_id, SUM(amount) AS total
FROM orders
GROUP BY customer_id;

-- Keeps all six rows, adds a total column
SELECT id, customer_id, amount,
       SUM(amount) OVER (PARTITION BY customer_id) AS customer_total
FROM orders;

PARTITION BY splits the rows into groups (the window), much like GROUP BY would, but without the collapse. Leave it out and the window is the whole result set.

Ranking: ROW_NUMBER, RANK and DENSE_RANK

Back to your manager's request. "Top three orders per customer" is the classic top-N-per-group problem, and ROW_NUMBER() solves it in a few lines.

SELECT *
FROM (
  SELECT id, customer_id, order_date, amount,
         ROW_NUMBER() OVER (
           PARTITION BY customer_id
           ORDER BY amount DESC
         ) AS rn
  FROM orders
) AS ranked
WHERE rn <= 3;

Notice the subquery. You cannot filter on a window function in the same WHERE clause where it is computed, because WHERE runs before window functions are evaluated. Wrapping the query (or using a CTE) is the standard pattern, and it reads far better than the self-join alternative.

The three ranking functions differ only in how they treat ties:

AD
FunctionTies getSequence for values 300, 300, 200
ROW_NUMBER()Different numbers, arbitrarily1, 2, 3
RANK()Same rank, then a gap1, 1, 3
DENSE_RANK()Same rank, no gap1, 1, 2

Pick ROW_NUMBER() when you need exactly N rows, and DENSE_RANK() when "second place" should still mean the second-highest distinct value. If ties matter and you want deterministic output, add a tiebreaker such as id to the ORDER BY.

Looking Sideways: LAG and LEAD

The second half of the request, comparing each order to the previous one, used to be the painful part. With LAG() it is one expression.

SELECT id, customer_id, order_date, amount,
       LAG(amount) OVER (
         PARTITION BY customer_id
         ORDER BY order_date
       ) AS previous_amount,
       amount - LAG(amount) OVER (
         PARTITION BY customer_id
         ORDER BY order_date
       ) AS change_from_previous
FROM orders;

For each customer's first order there is no previous row, so LAG returns NULL. You can supply a default as the third argument, as in LAG(amount, 1, 0), which means "look back one row, and use 0 if nothing is there." LEAD() does the mirror image and looks forward.

Because the same window appears twice, MySQL lets you name it once and reuse it:

SELECT id, customer_id, amount,
       LAG(amount) OVER w AS previous_amount,
       amount - LAG(amount) OVER w AS change_from_previous
FROM orders
WINDOW w AS (PARTITION BY customer_id ORDER BY order_date);

That WINDOW w AS (...) clause is a small readability win that pays off the moment a query has three or four window expressions.

Running Totals and Moving Averages

The other everyday use is cumulative math. Add an ORDER BY inside OVER() with an aggregate, and you get a running total:

AD
SELECT customer_id, order_date, amount,
       SUM(amount) OVER (
         PARTITION BY customer_id
         ORDER BY order_date
       ) AS running_total
FROM orders;

Behind the scenes, the default frame when you include ORDER BY is "from the first row up to the current row." For a moving average you control the frame explicitly:

SELECT order_date, amount,
       AVG(amount) OVER (
         ORDER BY order_date
         ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
       ) AS moving_avg_3
FROM orders;

This averages the current order and the two before it. One subtle trap: with the default RANGE frame, rows that tie on the ORDER BY value are treated as peers and included together. If you want strict row-by-row behavior, write ROWS explicitly, as above. It is a habit worth building early.

When a window result looks "off by a tie," check the frame first. Nine times out of ten the default RANGE is the culprit.

Pitfalls and Performance Notes

Window functions are elegant, but they are not free. A few lessons that save real debugging time:

  • They run after WHERE, GROUP BY and HAVING. That is why you filter on them from an outer query or CTE, never in the same WHERE.
  • Sorting is the cost. Each distinct PARTITION BY ... ORDER BY combination may need its own sort. An index that matches the partition and order columns, such as (customer_id, order_date), can help noticeably on large tables.
  • Use EXPLAIN. Check whether MySQL is sorting via a temporary file before you blame the query logic.
  • Mind the version. Window functions require MySQL 8.0 or later. If you are still on 5.7, they simply do not exist, and you will see a syntax error.

A tidy habit is to build the query in stages: first the windowed SELECT, confirm the numbers on a handful of rows you can verify by hand, then wrap it in a CTE and apply your filter.

WITH ranked AS (
  SELECT id, customer_id, amount,
         ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY amount DESC) AS rn
  FROM orders
)
SELECT * FROM ranked WHERE rn <= 3;

Summary

Window functions let you compute across related rows while keeping every row in your result. Use PARTITION BY to define the groups, ORDER BY to define sequence, and reach for ROW_NUMBER() for top-N, LAG()/LEAD() for row-to-row comparisons, and SUM() OVER for running totals. Name repeated windows with a WINDOW clause, filter through a CTE, and check the frame when ties surprise you.

The next time a request starts with "for each customer, compare to the previous...", you will know the answer is not another join. It is one line after OVER. Happy querying, and may your Thursday afternoons stay quiet.