Learn  /  Guides

SQL window functions, explained with real examples

The single most useful SQL skill after joins. Rankings, running totals and period-over-period comparisons, without self-joins.

Guide9 min readUpdated 18 October 2026
SQLDatabases
All guides and cheat sheets

Window functions let you calculate across a set of rows related to the current row while keeping every row in the result. That is the key difference from GROUP BY, which collapses rows into one per group. If you have ever needed "each order, plus the customer's running total" or "each month, plus last month's figure", window functions are the answer.

The anatomy of a window function

Every window function follows the same shape: a function, then OVER, then an optional partition, order and frame.

SQL
function_name(expression) OVER (
    PARTITION BY group_column      -- restart the calculation for each group
    ORDER BY sort_column           -- the order rows are processed in
    ROWS BETWEEN ... AND ...       -- which rows around the current one to include
)
Reading a window function
FunctionSUM, ROW_NUMBER, LAG
OVERit is a window function
PARTITION BYrestart per group
ORDER BYprocess in sequence
Framewhich rows to include

All the examples below use a simple orders table with order_id, customer_id, order_date and amount.

Numbering and ranking rows

ROW_NUMBER, RANK and DENSE_RANK look similar but treat ties differently.

SQL
SELECT
    customer_id,
    order_id,
    amount,
    ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY amount DESC) AS row_num,
    RANK()       OVER (PARTITION BY customer_id ORDER BY amount DESC) AS rnk,
    DENSE_RANK() OVER (PARTITION BY customer_id ORDER BY amount DESC) AS dense_rnk
FROM orders;

ROW_NUMBER

1, 2, 3
Ties get
different numbers
After a tie
continues

Use it for de-duplication and picking exactly one row.

RANK

1, 1, 3
Ties get
the same number
After a tie
skips

Use it for competition-style rankings.

DENSE_RANK

1, 1, 2
Ties get
the same number
After a tie
no gap

Use it for "top 3 distinct values".

Top N per group

A classic interview question and an everyday task: the latest order for each customer. Rank inside a subquery or CTE, then filter.

SQL
WITH ranked AS (
    SELECT
        *,
        ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) AS rn
    FROM orders
)
SELECT *
FROM ranked
WHERE rn = 1;

Snowflake, BigQuery and DuckDB support QUALIFY, which filters on a window function directly and removes the need for the CTE:

SQL
SELECT *
FROM orders
QUALIFY ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) = 1;

Comparing with the previous row: LAG and LEAD

LAG reads a value from an earlier row, LEAD from a later one. Perfect for month-on-month change or the time between a customer's orders.

SQL
WITH monthly AS (
    SELECT DATE_TRUNC('month', order_date) AS month, SUM(amount) AS revenue
    FROM orders
    GROUP BY 1
)
SELECT
    month,
    revenue,
    LAG(revenue) OVER (ORDER BY month) AS prev_revenue,
    ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY month))
          / NULLIF(LAG(revenue) OVER (ORDER BY month), 0), 1) AS pct_change
FROM monthly
ORDER BY month;

Running totals and moving averages

Add ORDER BY to an aggregate and it becomes cumulative. Add a frame and you control exactly how many rows it looks at.

SQL
SELECT
    order_date,
    amount,
    SUM(amount) OVER (
        ORDER BY order_date
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS running_total,
    AVG(amount) OVER (
        ORDER BY order_date
        ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
    ) AS moving_avg_7_rows
FROM daily_sales;

Share of total without a second query

An empty OVER () means "the whole result". That makes percentage-of-total calculations a one-liner.

SQL
SELECT
    product,
    SUM(amount) AS revenue,
    ROUND(100.0 * SUM(amount) / SUM(SUM(amount)) OVER (), 1) AS pct_of_total
FROM orders
GROUP BY product
ORDER BY revenue DESC;

SUM(SUM(amount)) OVER () looks odd at first: the inner SUM is the grouped total per product, and the outer windowed SUM adds those totals across all products.

Common mistakes

Filtering in WHERE
Window functions are calculated after WHERE, so you cannot reference them there. Use a CTE or QUALIFY.
Forgetting PARTITION BY
Rankings run across the whole table instead of per customer or per product.
Non-deterministic ORDER BY
If two rows share the same date, ROW_NUMBER may pick either. Add a tie-breaker such as order_id.

Check your understanding

1/3Three orders have amounts 100, 100 and 80. What does RANK give them, highest first?

2/3How do you keep only the latest order per customer in Snowflake, BigQuery or DuckDB without a CTE?

3/3What does an empty OVER () mean?

Keep the SQL cheat sheet open while you practise. It has every pattern above on one printable page. PostgreSQL users: PostgreSQL 19 adds IGNORE NULLS to window functions, and Apache Spark 4.2 brings QUALIFY to Spark SQL.

Alessandro Ecclesie AgazziWritten by Alessandro Ecclesie Agazzi, analytics engineer in London. Updated 30 September 2026. About me

Found this useful?

New guides and "what's new" explainers on data and AI land here regularly. Follow along on LinkedIn, or share this one with a colleague.