All resources SQL

Window Functions Without the Fear

If you have ever written a subquery to rank rows within a group, this is the feature you were missing.

10 min read Intermediate Updated September 2026

A GROUP BY collapses many rows into one. A window function does the calculation without collapsing anything, so each row keeps its own detail and gains a number that was computed across its neighbours. That single difference is what makes rankings, running totals and period-on-period comparisons straightforward.

The OVER clause is the whole idea

Every window function is an ordinary aggregate followed by OVER (...). What goes inside the brackets defines the window: which rows this row is allowed to see, and in what order.

SELECT  region,
        month,
        revenue,
        SUM(revenue) OVER (PARTITION BY region ORDER BY month) AS running_total
FROM    monthly_sales;
  • PARTITION BY restarts the calculation per group, the way GROUP BY splits rows, except nothing is collapsed.
  • ORDER BY inside the window decides what "so far" means, which is what turns a SUM into a running total.

Three ranking functions, and which to use

They differ only in how they handle ties, and choosing the wrong one is the most common bug in a leaderboard query.

FunctionTies becomeUse it for
ROW_NUMBER()1, 2, 3Picking exactly one row per group, such as the latest order per customer.
RANK()1, 1, 3A leaderboard where two equal scores should share a place.
DENSE_RANK()1, 1, 2Grades or bands, where you do not want a gap after a tie.
SELECT *
FROM (
  SELECT  customer_id,
          order_id,
          ordered_at,
          ROW_NUMBER() OVER (PARTITION BY customer_id
                             ORDER BY ordered_at DESC) AS rn
  FROM    orders
) t
WHERE rn = 1;
You cannot filter on a window function directly

A window function is calculated after WHERE runs, so WHERE ROW_NUMBER() = 1 is invalid. Wrap the query and filter outside it, as above, or use QUALIFY where your database supports it.

Comparing a row with the one before it

LAG reaches backwards and LEAD reaches forwards. Month-on-month growth, which normally needs a self join on a date offset, becomes one line.

SELECT  month,
        revenue,
        revenue - LAG(revenue) OVER (ORDER BY month) AS change,
        ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY month))
              / LAG(revenue) OVER (ORDER BY month), 1) AS pct_change
FROM    monthly_sales;

The first row has nothing behind it, so its change is NULL. That is correct, and worth leaving visible rather than filling with a zero that implies no growth.

When a window is the wrong tool

If the answer genuinely has one row per group and no detail is needed, GROUP BY is simpler and faster. Windows earn their cost when you need the detail rows and the summary together, or when the calculation depends on order.

Key takeaways

  • PARTITION BY groups without collapsing; ORDER BY inside the window creates the running behaviour.
  • ROW_NUMBER for one row per group, RANK for shared places, DENSE_RANK for bands.
  • Filter on a window result in an outer query, never in the same WHERE.
  • LAG and LEAD replace a self join for any period-on-period comparison.

Turn this into a career, not a bookmark.

Data Science & Analytics with AI takes you from your first query to a portfolio employers read.

Explore the course