MySQLWindow Functions

Window Functions in MySQL

Window functions compute a value for each row based on a set of rows related to the current row — the "window". Unlike aggregate functions with GROUP BY, window functions do not collapse rows. Each row in the result keeps its own identity while also having access to summary information from the surrounding rows.

Window functions were added in MySQL 8.0.

Window Function Syntax

SQL
function_name(column) OVER (
  [PARTITION BY partition_columns]
  [ORDER BY sort_columns]
  [ROWS | RANGE BETWEEN frame_start AND frame_end]
)

-- OVER() is what makes it a window function
-- PARTITION BY splits rows into independent windows
-- ORDER BY controls the order within each window
-- ROWS/RANGE defines the frame (which rows to include in the calculation)
Window vs Aggregate Functions

SQL
-- GROUP BY aggregate: collapses rows, one row per customer
SELECT customer_id, SUM(total) AS total_spent
FROM orders GROUP BY customer_id;

-- Window function: keeps all rows, adds the total alongside each row
SELECT
  order_id,
  customer_id,
  total,
  SUM(total) OVER (PARTITION BY customer_id) AS customer_total_spent
FROM orders
WHERE status = 'delivered';
Note
Window functions are evaluated after WHERE, GROUP BY, and HAVING but before ORDER BY. This means you cannot use a window function result directly in a WHERE clause — wrap the query in a subquery or CTE first.
PARTITION BY — Splitting Into Windows

SQL
-- Per-category stats alongside each product row
SELECT
  product_id,
  name,
  category_id,
  price,
  COUNT(*)   OVER (PARTITION BY category_id)    AS products_in_category,
  AVG(price) OVER (PARTITION BY category_id)    AS category_avg_price,
  MAX(price) OVER (PARTITION BY category_id)    AS category_max_price,
  price - AVG(price) OVER (PARTITION BY category_id) AS price_vs_category_avg
FROM products
WHERE is_active = 1
ORDER BY category_id, price DESC;
ROW_NUMBER — Unique Rank Within Partition

SQL
-- Number rows within each customer's orders by date
SELECT
  order_id,
  customer_id,
  total,
  created_at,
  ROW_NUMBER() OVER (
    PARTITION BY customer_id
    ORDER BY created_at
  ) AS order_sequence
FROM orders
WHERE status = 'delivered'
ORDER BY customer_id, order_sequence;

-- Get only the first order per customer (using ROW_NUMBER in a subquery)
SELECT * FROM (
  SELECT
    *,
    ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at) AS rn
  FROM orders
  WHERE status = 'delivered'
) AS ranked
WHERE rn = 1;
RANK and DENSE_RANK
  • RANK() — assigns the same rank to ties, then skips the next rank(s). Ranks 1, 1, 3.
  • DENSE_RANK() — assigns the same rank to ties, does not skip. Ranks 1, 1, 2.
  • ROW_NUMBER() — always assigns a unique number even for ties.

SQL
SELECT
  product_id,
  name,
  category_id,
  price,
  RANK()       OVER (PARTITION BY category_id ORDER BY price DESC) AS rank_in_cat,
  DENSE_RANK() OVER (PARTITION BY category_id ORDER BY price DESC) AS dense_rank_in_cat,
  ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY price DESC) AS row_num_in_cat
FROM products
WHERE is_active = 1
ORDER BY category_id, price DESC;

-- Top 3 products by revenue per category (using DENSE_RANK)
SELECT * FROM (
  SELECT
    cat.name         AS category,
    p.name           AS product,
    SUM(oi.quantity * oi.unit_price) AS revenue,
    DENSE_RANK() OVER (
      PARTITION BY p.category_id
      ORDER BY SUM(oi.quantity * oi.unit_price) DESC
    ) AS revenue_rank
  FROM order_items AS oi
  JOIN products    AS p   ON oi.product_id = p.product_id
  JOIN categories  AS cat ON p.category_id = cat.category_id
  JOIN orders      AS o   ON oi.order_id   = o.order_id
  WHERE o.status = 'delivered'
  GROUP BY p.category_id, cat.name, p.product_id, p.name
) AS ranked
WHERE revenue_rank <= 3
ORDER BY category, revenue_rank;
NTILE — Dividing Rows Into Buckets

SQL
-- Divide customers into 4 spending quartiles
SELECT
  customer_id,
  ROUND(SUM(total), 2)     AS total_spent,
  NTILE(4) OVER (ORDER BY SUM(total) DESC) AS spending_quartile
FROM orders
WHERE status = 'delivered'
GROUP BY customer_id
ORDER BY total_spent DESC;

-- Label the quartiles
SELECT
  customer_id,
  total_spent,
  CASE spending_quartile
    WHEN 1 THEN 'Top 25%'
    WHEN 2 THEN 'Upper-middle 25%'
    WHEN 3 THEN 'Lower-middle 25%'
    WHEN 4 THEN 'Bottom 25%'
  END AS spending_tier
FROM (
  SELECT
    customer_id,
    ROUND(SUM(total), 2)                            AS total_spent,
    NTILE(4) OVER (ORDER BY SUM(total) DESC)        AS spending_quartile
  FROM orders WHERE status = 'delivered'
  GROUP BY customer_id
) AS tiers
ORDER BY total_spent DESC;
LAG and LEAD — Accessing Adjacent Rows
  • LAG(col, n) — returns the value of col from n rows before the current row.
  • LEAD(col, n) — returns the value of col from n rows after the current row.
  • Both accept a default value as a third argument if the offset goes out of bounds.

SQL
-- Month-over-month revenue comparison
WITH monthly AS (
  SELECT
    DATE_FORMAT(created_at, '%Y-%m') AS month,
    ROUND(SUM(total), 2) AS revenue
  FROM orders
  WHERE status = 'delivered'
  GROUP BY DATE_FORMAT(created_at, '%Y-%m')
)
SELECT
  month,
  revenue,
  LAG(revenue, 1, 0) OVER (ORDER BY month) AS prev_month_revenue,
  ROUND(revenue - LAG(revenue, 1, 0) OVER (ORDER BY month), 2) AS growth,
  ROUND(
    (revenue - LAG(revenue, 1) OVER (ORDER BY month)) * 100.0 /
    NULLIF(LAG(revenue, 1) OVER (ORDER BY month), 0),
  1) AS growth_pct
FROM monthly
ORDER BY month;

-- Show each order alongside the next order date for that customer
SELECT
  order_id,
  customer_id,
  created_at,
  LEAD(created_at) OVER (
    PARTITION BY customer_id
    ORDER BY created_at
  ) AS next_order_date
FROM orders
WHERE status = 'delivered'
ORDER BY customer_id, created_at;
FIRST_VALUE and LAST_VALUE

SQL
-- For each order, show the customer's very first and most recent order total
SELECT
  order_id,
  customer_id,
  total,
  created_at,
  FIRST_VALUE(total) OVER (
    PARTITION BY customer_id
    ORDER BY created_at
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
  ) AS first_order_total,
  LAST_VALUE(total) OVER (
    PARTITION BY customer_id
    ORDER BY created_at
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
  ) AS last_order_total
FROM orders
WHERE status = 'delivered'
ORDER BY customer_id, created_at;
Warning
Without a frame clause, LAST_VALUE defaults to ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, which means it returns the current row's value, not the last row in the partition. Always specify ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING for LAST_VALUE to work as expected.
Running Totals with SUM as a Window Function

SQL
-- Cumulative revenue over time (running total)
SELECT
  DATE(created_at)  AS order_date,
  total,
  SUM(total) OVER (ORDER BY created_at ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
                    AS cumulative_revenue
FROM orders
WHERE status = 'delivered'
ORDER BY created_at;

-- 7-day rolling average of daily revenue
WITH daily_revenue AS (
  SELECT
    DATE(created_at) AS order_date,
    ROUND(SUM(total), 2) AS daily_total
  FROM orders
  WHERE status = 'delivered'
  GROUP BY DATE(created_at)
)
SELECT
  order_date,
  daily_total,
  ROUND(AVG(daily_total) OVER (
    ORDER BY order_date
    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
  ), 2) AS rolling_7day_avg
FROM daily_revenue
ORDER BY order_date;
ROWS vs RANGE Frame Specification

The frame clause defines which rows in the window are included in the calculation:

  • ROWS — counts rows by position (physical). Precise and usually what you want.
  • RANGE — counts rows by logical value range. Can include duplicate values in the frame.

SQL
-- ROWS frame: exactly 3 preceding rows
AVG(revenue) OVER (ORDER BY month ROWS BETWEEN 3 PRECEDING AND CURRENT ROW)

-- RANGE frame: all rows with the same ORDER BY value as current row
SUM(total) OVER (ORDER BY order_date RANGE BETWEEN INTERVAL 7 DAY PRECEDING AND CURRENT ROW)
-- MySQL 8.0.2+ supports RANGE with date/time intervals

-- Common frame shortcuts
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW   -- cumulative
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING -- entire partition
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING           -- 3-row centered average
Practical Analytics Examples

SQL
-- Customer lifetime value with ranking and percentile
SELECT
  c.customer_id,
  c.first_name,
  c.country,
  ROUND(SUM(o.total), 2)                          AS lifetime_value,
  COUNT(DISTINCT o.order_id)                      AS order_count,
  RANK() OVER (ORDER BY SUM(o.total) DESC)        AS global_rank,
  RANK() OVER (
    PARTITION BY c.country
    ORDER BY SUM(o.total) DESC
  )                                               AS country_rank,
  ROUND(
    PERCENT_RANK() OVER (ORDER BY SUM(o.total)) * 100,
  1)                                              AS percentile
FROM customers AS c
JOIN orders    AS o ON c.customer_id = o.customer_id
WHERE o.status = 'delivered'
GROUP BY c.customer_id, c.first_name, c.country
ORDER BY lifetime_value DESC
LIMIT 50;
Window Function Quick Reference

Function

Description

Requires ORDER BY?

ROW_NUMBER()

Unique sequential number within partition

Yes

RANK()

Rank with gaps on ties (1, 1, 3)

Yes

DENSE_RANK()

Rank without gaps on ties (1, 1, 2)

Yes

NTILE(n)

Divide rows into n equal-sized buckets

Yes

LAG(col, n)

Value from n rows before current

Yes

LEAD(col, n)

Value from n rows after current

Yes

FIRST_VALUE(col)

First value in the window frame

Usually yes

LAST_VALUE(col)

Last value in the window frame

Yes — add full frame clause

PERCENT_RANK()

Relative rank as 0.0 to 1.0

Yes

CUME_DIST()

Cumulative distribution (0.0 to 1.0)

Yes

SUM / AVG / MIN / MAX

Aggregate as window function

Optional

  • Window functions do not reduce row count — unlike GROUP BY aggregates

  • PARTITION BY creates independent windows — like GROUP BY but without collapsing rows

  • Always add a frame clause to LAST_VALUE to get correct results

  • Use ROW_NUMBER in a subquery to get the top N rows per group

  • LAG and LEAD are the easiest way to compute period-over-period changes

  • Window functions cannot be used in WHERE — filter in a subquery or CTE