SQL BigQueryPostgreSQL 2026-05-05 10 min

SQL Window Functions: Complete Guide for Data Analysts

Window functions are the most powerful SQL feature for analytical queries — and the one most often missing from junior analysts' toolkit. From basic ranking to moving averages and period-over-period comparisons.

Isachenko Andrii
Isachenko Andrii
Data Analyst · Open to work

📋 Table of Contents

  1. What Are Window Functions and Why They Matter
  2. Core Syntax
  3. Ranking Functions: ROW_NUMBER, RANK, DENSE_RANK
  4. Aggregate Window Functions
  5. LAG and LEAD: Period-over-Period Comparisons
  6. Window Frames: ROWS vs RANGE
  7. Real Analyst Use Cases

What Are Window Functions and Why They Matter

Window functions perform calculations across a set of rows related to the current row — without collapsing those rows into a single output like GROUP BY does. They're the single most powerful SQL feature for analytical queries, and the one most often missing from junior analysts' toolkit.

Every ranking, running total, moving average, period-over-period comparison, and row-numbering task in SQL can be written more clearly and efficiently with window functions than with self-joins or subqueries.

💡 If you're writing a subquery or CTE just to number rows or compare to a previous row, a window function will do it in one pass.

Core Syntax

All window functions share the same structure: a function name, followed by OVER() which defines the window (the set of rows to operate on).

function_name(expression)
OVER (
  PARTITION BY column   -- divide rows into groups
  ORDER BY column       -- define order within each group
  ROWS/RANGE BETWEEN ... AND ...  -- optional frame
)

The PARTITION BY clause is like GROUP BY but doesn't collapse rows. The ORDER BY inside OVER() is completely independent of the query's final ORDER BY.

Ranking Functions: ROW_NUMBER, RANK, DENSE_RANK

-- Number each customer's orders chronologically
SELECT
  order_id,
  customer_id,
  order_date,
  revenue,
  ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date) AS order_num,
  RANK()       OVER (PARTITION BY customer_id ORDER BY revenue DESC) AS revenue_rank,
  DENSE_RANK() OVER (PARTITION BY customer_id ORDER BY revenue DESC) AS revenue_dense_rank
FROM orders;
FunctionTies handlingUse case
ROW_NUMBER()Arbitrary order for tiesDeduplication, pagination
RANK()Same rank, then gapCompetition rankings
DENSE_RANK()Same rank, no gapCategory rankings
NTILE(n)Divides into n bucketsQuartiles, deciles

Practical: Get each customer's most recent order only

-- Filter to last order per customer using ROW_NUMBER
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;

Aggregate Window Functions

All standard aggregate functions (SUM, AVG, COUNT, MIN, MAX) work as window functions when combined with OVER().

SELECT
  order_date,
  revenue,
  -- Running total (cumulative sum)
  SUM(revenue) OVER (ORDER BY order_date) AS running_total,
  -- % of grand total
  ROUND(revenue / SUM(revenue) OVER () * 100, 1) AS pct_of_total,
  -- 7-day moving average
  AVG(revenue) OVER (ORDER BY order_date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma_7d
FROM daily_revenue;

LAG and LEAD: Period-over-Period Comparisons

LAG() accesses the previous row's value; LEAD() accesses the next row. These replace complex self-joins for MoM/YoY calculations.

SELECT
  month,
  revenue,
  LAG(revenue) OVER (ORDER BY month) AS prev_month_revenue,
  -- Month-over-month growth %
  ROUND(
    (revenue - LAG(revenue) OVER (ORDER BY month))
    / LAG(revenue) OVER (ORDER BY month) * 100, 1
  ) AS mom_growth_pct
FROM monthly_revenue;

Window Frames: ROWS vs RANGE

The frame clause controls exactly which rows are included in the calculation. This is critical for running totals and moving averages.

-- Common frame patterns:
SUM(revenue) OVER (ORDER BY date
  ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)  -- running total
AVG(revenue) OVER (ORDER BY date
  ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)          -- 3-day moving avg
SUM(revenue) OVER (ORDER BY date
  ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)  -- reverse running total
SUM(revenue) OVER ()                                  -- grand total (all rows)

Real Analyst Use Cases

TaskWindow function
Find first purchase per customerROW_NUMBER() + filter rn=1
Calculate running revenueSUM() OVER (ORDER BY date)
Month-over-month growthLAG() OVER (ORDER BY month)
Rank products by salesDENSE_RANK() OVER (ORDER BY sales DESC)
7-day moving averageAVG() OVER (ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)
What % of total is each rowvalue / SUM() OVER () * 100
Assign customers to decilesNTILE(10) OVER (ORDER BY revenue DESC)
⚠️ Window functions execute after WHERE and GROUP BY, but before the final SELECT projection. You cannot filter on a window function result in the same query level — wrap it in a CTE or subquery first.
Tags: SQL Window Functions BigQuery PostgreSQL Analytics