Toolmingo
Guides11 min read

SQL Window Functions: Complete Guide with Examples

Master SQL window functions: ROW_NUMBER, RANK, LAG, LEAD, SUM OVER, running totals, moving averages, and more. With real examples in PostgreSQL, MySQL, and SQLite.

SQL window functions perform calculations across a set of rows related to the current row — without collapsing the result into a single value like GROUP BY does. They unlock patterns that are nearly impossible with standard aggregation: running totals, moving averages, rank within a group, previous-row comparisons, and more.

This guide covers every window function with working examples you can run in PostgreSQL, MySQL 8+, or SQLite 3.25+.


Quick Reference

Category Functions
Ranking ROW_NUMBER(), RANK(), DENSE_RANK(), NTILE(n)
Offset LAG(), LEAD(), FIRST_VALUE(), LAST_VALUE(), NTH_VALUE()
Aggregate SUM(), COUNT(), AVG(), MIN(), MAX() — all with OVER()
Distribution PERCENT_RANK(), CUME_DIST()

Anatomy of a Window Function

function_name(expression)
OVER (
  PARTITION BY column1, column2   -- optional: divide rows into groups
  ORDER BY column3 DESC           -- optional: order within each group
  ROWS BETWEEN ... AND ...        -- optional: limit which rows are included
)
Clause Purpose
OVER() Required — tells SQL this is a window function
PARTITION BY Resets the calculation for each group (like GROUP BY, but keeps all rows)
ORDER BY Sets the order rows are processed within each partition
ROWS/RANGE BETWEEN Defines the window frame (which rows to include)

Setup: Sample Tables

CREATE TABLE sales (
  id          INT,
  rep         VARCHAR(50),
  region      VARCHAR(50),
  sale_date   DATE,
  amount      DECIMAL(10,2)
);

INSERT INTO sales VALUES
  (1,  'Alice', 'North', '2025-01-05', 1200.00),
  (2,  'Bob',   'North', '2025-01-08', 850.00),
  (3,  'Alice', 'North', '2025-01-15', 2100.00),
  (4,  'Carol', 'South', '2025-01-10', 950.00),
  (5,  'Dave',  'South', '2025-01-12', 1750.00),
  (6,  'Carol', 'South', '2025-02-01', 3200.00),
  (7,  'Bob',   'North', '2025-02-05', 1100.00),
  (8,  'Dave',  'South', '2025-02-08', 600.00),
  (9,  'Alice', 'North', '2025-02-20', 1800.00),
  (10, 'Carol', 'South', '2025-03-01', 2500.00);

1. Ranking Functions

ROW_NUMBER()

Assigns a unique sequential integer to each row within its partition. Ties get different numbers.

SELECT
  rep,
  region,
  amount,
  ROW_NUMBER() OVER (PARTITION BY region ORDER BY amount DESC) AS row_num
FROM sales;
rep region amount row_num
Alice North 2100.00 1
Alice North 1800.00 2
Bob North 1100.00 3
Bob North 850.00 4
Carol South 3200.00 1
Carol South 2500.00 2
Dave South 1750.00 3
Carol South 950.00 4
Dave South 600.00 5

Common use case: Get the top N rows per group.

-- Top 2 sales per region
SELECT * FROM (
  SELECT *,
    ROW_NUMBER() OVER (PARTITION BY region ORDER BY amount DESC) AS rn
  FROM sales
) ranked
WHERE rn <= 2;

RANK() and DENSE_RANK()

Both assign the same rank to tied rows. They differ in how they handle the next rank after a tie:

Function Ties get same rank? Next rank after tie
RANK() Yes Skips (gaps)
DENSE_RANK() Yes No gaps
ROW_NUMBER() No Sequential always
SELECT
  rep,
  amount,
  RANK()       OVER (ORDER BY amount DESC) AS rnk,
  DENSE_RANK() OVER (ORDER BY amount DESC) AS dense_rnk,
  ROW_NUMBER() OVER (ORDER BY amount DESC) AS row_num
FROM sales;

Example output when Alice has 2100 and Carol has 2100 (tied):

rep amount rnk dense_rnk row_num
Alice 2100 1 1 1
Carol 2100 1 1 2
Carol 1800 3 2 3

RANK() skips rank 2; DENSE_RANK() does not.


NTILE(n)

Divides rows into n equally-sized buckets and assigns a bucket number.

SELECT
  rep,
  amount,
  NTILE(4) OVER (ORDER BY amount DESC) AS quartile
FROM sales;

Use NTILE(100) for percentiles, NTILE(10) for deciles.


2. Offset Functions

LAG() and LEAD()

Access a value from a previous (LAG) or following (LEAD) row within the partition.

LAG(expression, offset, default) OVER (...)
LEAD(expression, offset, default) OVER (...)
Parameter Default Description
expression Column or expression to look up
offset 1 How many rows back (LAG) or forward (LEAD)
default NULL Value to return if no previous/next row exists

Month-over-month growth:

SELECT
  rep,
  sale_date,
  amount,
  LAG(amount, 1, 0) OVER (PARTITION BY rep ORDER BY sale_date) AS prev_amount,
  amount - LAG(amount, 1, 0) OVER (PARTITION BY rep ORDER BY sale_date) AS change
FROM sales;
rep sale_date amount prev_amount change
Alice 2025-01-05 1200.00 0.00 1200.00
Alice 2025-01-15 2100.00 1200.00 900.00
Alice 2025-02-20 1800.00 2100.00 -300.00

FIRST_VALUE() and LAST_VALUE()

Return the first or last value in the window frame.

SELECT
  rep,
  sale_date,
  amount,
  FIRST_VALUE(amount) OVER (
    PARTITION BY rep ORDER BY sale_date
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
  ) AS first_sale,
  LAST_VALUE(amount) OVER (
    PARTITION BY rep ORDER BY sale_date
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
  ) AS last_sale
FROM sales;

Important: LAST_VALUE uses a default frame of ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, so you almost always need to extend it to UNBOUNDED FOLLOWING to get the actual last value.


NTH_VALUE()

Returns the value of a specific row within the window frame.

SELECT
  rep,
  amount,
  NTH_VALUE(amount, 2) OVER (
    PARTITION BY region ORDER BY amount DESC
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
  ) AS second_highest
FROM sales;

3. Aggregate Window Functions

Any standard aggregate (SUM, COUNT, AVG, MIN, MAX) can be used with OVER() to compute running or windowed values without collapsing rows.

Running Total

SELECT
  rep,
  sale_date,
  amount,
  SUM(amount) OVER (ORDER BY sale_date) AS running_total
FROM sales;
rep sale_date amount running_total
Alice 2025-01-05 1200.00 1200.00
Bob 2025-01-08 850.00 2050.00
Carol 2025-01-10 950.00 3000.00
Dave 2025-01-12 1750.00 4750.00
Alice 2025-01-15 2100.00 6850.00

Running Total per Partition

SELECT
  rep,
  sale_date,
  amount,
  SUM(amount) OVER (
    PARTITION BY rep
    ORDER BY sale_date
  ) AS rep_running_total
FROM sales;

Each rep's running total resets independently.


Moving Average (7-day)

SELECT
  sale_date,
  amount,
  AVG(amount) OVER (
    ORDER BY sale_date
    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
  ) AS moving_avg_7d
FROM sales;

Percentage of Total

SELECT
  rep,
  region,
  amount,
  ROUND(
    100.0 * amount / SUM(amount) OVER (PARTITION BY region),
    2
  ) AS pct_of_region
FROM sales;

4. Window Frame Clause

The frame clause limits which rows are included in aggregate calculations.

ROWS BETWEEN start AND end
RANGE BETWEEN start AND end

Frame boundary options:

Boundary Meaning
UNBOUNDED PRECEDING From the first row of the partition
n PRECEDING n rows before current row
CURRENT ROW Only the current row
n FOLLOWING n rows after current row
UNBOUNDED FOLLOWING To the last row of the partition

ROWS vs RANGE:

ROWS RANGE
Counts by Physical row position Logical value
Ties Each row treated separately All tied rows included together
Use when Offset-based window (N rows back) Value-based window (date range)

Default frame (when ORDER BY is present): RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW


5. Distribution Functions

PERCENT_RANK()

Returns the relative rank of a row as a value between 0 and 1.

(rank - 1) / (total rows - 1)
SELECT
  rep,
  amount,
  ROUND(PERCENT_RANK() OVER (ORDER BY amount), 4) AS pct_rank
FROM sales;

The lowest value gets 0.0; the highest gets 1.0.


CUME_DIST()

Returns the cumulative distribution — the fraction of rows with values ≤ the current row.

SELECT
  rep,
  amount,
  ROUND(CUME_DIST() OVER (ORDER BY amount), 4) AS cume_dist
FROM sales;

CUME_DIST always gives a value > 0 (unlike PERCENT_RANK which can be 0).


6. Common Patterns

Top N per Group

-- Top 3 sales reps per region by total sales
SELECT region, rep, total FROM (
  SELECT
    region,
    rep,
    SUM(amount) AS total,
    RANK() OVER (PARTITION BY region ORDER BY SUM(amount) DESC) AS rnk
  FROM sales
  GROUP BY region, rep
) ranked
WHERE rnk <= 3;

Previous Row Comparison (YoY, MoM)

SELECT
  rep,
  EXTRACT(MONTH FROM sale_date) AS month,
  SUM(amount) AS monthly_total,
  LAG(SUM(amount)) OVER (
    PARTITION BY rep
    ORDER BY EXTRACT(MONTH FROM sale_date)
  ) AS prev_month_total,
  ROUND(
    100.0 * (SUM(amount) - LAG(SUM(amount)) OVER (
      PARTITION BY rep ORDER BY EXTRACT(MONTH FROM sale_date)
    )) / NULLIF(LAG(SUM(amount)) OVER (
      PARTITION BY rep ORDER BY EXTRACT(MONTH FROM sale_date)
    ), 0),
    2
  ) AS mom_pct_change
FROM sales
GROUP BY rep, EXTRACT(MONTH FROM sale_date);

Deduplication (Keep Latest Row per Group)

-- Keep only the most recent sale per rep
SELECT * FROM (
  SELECT *,
    ROW_NUMBER() OVER (PARTITION BY rep ORDER BY sale_date DESC) AS rn
  FROM sales
) t
WHERE rn = 1;

Consecutive Streaks (Islands and Gaps)

-- Find groups of consecutive days with sales
SELECT
  rep,
  sale_date,
  ROW_NUMBER() OVER (PARTITION BY rep ORDER BY sale_date) AS rn,
  sale_date - ROW_NUMBER() OVER (PARTITION BY rep ORDER BY sale_date) * INTERVAL '1 day' AS grp
FROM sales;

Then GROUP BY rep, grp to count consecutive days in each streak.


Running Distinct Count

-- Approximate: count distinct reps seen so far (ordered by date)
SELECT
  sale_date,
  rep,
  COUNT(*) OVER (
    PARTITION BY rep
    ORDER BY sale_date
    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
  ) AS rep_sale_number
FROM sales;

7. Window Functions vs GROUP BY

GROUP BY Window Function
Output rows One per group Same as input
Aggregate Collapses rows Keeps all rows
Access other columns Only aggregated or grouped Any column
Can mix with non-aggregate No Yes

Example — GROUP BY:

SELECT region, SUM(amount) AS total
FROM sales
GROUP BY region;
-- Returns 2 rows (one per region)

Window equivalent (keeps all rows):

SELECT rep, region, amount,
  SUM(amount) OVER (PARTITION BY region) AS region_total
FROM sales;
-- Returns 10 rows, each with the region total attached

8. Database Compatibility

Feature PostgreSQL MySQL 8+ SQLite 3.25+ SQL Server Oracle
ROW_NUMBER
RANK / DENSE_RANK
NTILE
LAG / LEAD
FIRST_VALUE / LAST_VALUE
NTH_VALUE
PERCENT_RANK / CUME_DIST
ROWS BETWEEN
RANGE BETWEEN (value-based) limited

MySQL added window function support in version 8.0. If you're on MySQL 5.7 or earlier, you'll need subqueries or variables to replicate this behavior.


Common Mistakes

Mistake Problem Fix
LAST_VALUE without full frame Returns current row, not the last Add ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
Using window function in WHERE Not allowed — window runs after WHERE Wrap in subquery and filter on the alias
Using window function in GROUP BY Not allowed Wrap in subquery first
Forgetting ORDER BY in LAG/LEAD Results are non-deterministic Always add ORDER BY
RANGE with ties and floats May include unexpected rows Use ROWS for physical-row windows
RANK vs ROW_NUMBER confusion RANK skips numbers on ties Use DENSE_RANK if gaps are unwanted
Treating PARTITION BY like GROUP BY PARTITION BY keeps all rows Output count equals input count
Reusing complex OVER() inline Hurts readability Use a CTE or subquery to alias the window

Named Windows

PostgreSQL and MySQL support defining a named window to reuse OVER clauses:

SELECT
  rep,
  amount,
  ROW_NUMBER() OVER w AS rn,
  RANK()       OVER w AS rnk,
  SUM(amount)  OVER w AS running_total
FROM sales
WINDOW w AS (PARTITION BY region ORDER BY sale_date);

FAQ

Can I filter on a window function result in WHERE? No. Window functions run after WHERE and GROUP BY. Wrap the query in a subquery or CTE:

SELECT * FROM (
  SELECT *, ROW_NUMBER() OVER (PARTITION BY region ORDER BY amount DESC) AS rn
  FROM sales
) t
WHERE rn = 1;

Can I use window functions with GROUP BY in the same query? Yes — but the window function is computed after grouping. The OVER() clause sees the grouped result, not individual rows:

SELECT rep, SUM(amount) AS total,
  RANK() OVER (ORDER BY SUM(amount) DESC) AS rnk
FROM sales
GROUP BY rep;

What's the difference between ROWS and RANGE? ROWS counts physical row offsets. RANGE uses logical value offsets. For most use cases (running totals, moving averages by row count), ROWS is what you want. Use RANGE only when you want to include all rows with the same value as the current row.

Do window functions work with CTEs? Yes, and CTEs are the best way to layer window functions:

WITH ranked AS (
  SELECT *, DENSE_RANK() OVER (PARTITION BY region ORDER BY amount DESC) AS drn
  FROM sales
)
SELECT * FROM ranked WHERE drn <= 3;

Are window functions slower than GROUP BY? Window functions add a sort step, which has O(n log n) cost. They are generally comparable to GROUP BY — and far faster than correlated subqueries for the same result. An index on (PARTITION BY column, ORDER BY column) can eliminate the sort.

Does SQLite support window functions? Yes, since version 3.25.0 (released September 2018). All modern SQLite distributions include window function support.

Related tools

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools