Senior 3 min · March 12, 2026

SQL Window Functions: No PARTITION BY Crashes 50M Rows

Unpartitioned SUM(revenue) OVER(ORDER BY date) on 50M rows hit 100% CPU and blocked all dashboards for 12 minutes.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Window functions compute values across related rows without collapsing them — unlike GROUP BY which removes individual rows
  • OVER() defines the window: PARTITION BY groups, ORDER BY defines ordering within the group, frame clause controls which rows are included
  • ROW_NUMBER() = unique sequential rank, RANK() = gaps on ties, DENSE_RANK() = no gaps on ties
  • LAG(col, n) / LEAD(col, n) access n rows before/after the current row — essential for period-over-period comparisons
  • Window functions cannot be filtered in the same SELECT where they are computed — wrap in a CTE or subquery
  • Biggest trap: large PARTITION BY with no supporting index causes filesort on every execution
Plain-English First

Imagine you're in a running race and you want to know your position — but you also want to see everyone else's position on the same leaderboard. GROUP BY would give you one row per runner (just the counts). A window function gives you each runner's row plus a computed position alongside it, without collapsing the data. You can see 'Alice: 1st place', 'Bob: 2nd place', 'Clara: 2nd place (tied)' all at once.

Every senior data engineer has hit the same wall: you need running totals, percentile ranks, or 'compare this row to its neighbours' logic, and suddenly GROUP BY feels like trying to paint a wall with a toothbrush. Window functions are the feature that separates analysts who write five nested subqueries from those who write one clean, efficient query.

The key mental model: a window function computes a value for each row using a defined set of related rows (the 'window') without removing any rows from the result. GROUP BY collapses rows into summaries. Window functions add derived columns to each row while leaving all rows intact.

The OVER Clause — How Windows Are Defined

Every window function requires an OVER clause — this is what makes it a window function rather than a regular aggregate. The OVER clause defines the window: the set of rows the function can 'see' when computing its value for the current row.

OVER() with nothing inside it means the window is the entire result set. PARTITION BY inside OVER divides the result set into groups — the function computes separately within each partition. ORDER BY inside OVER defines the ordering within each partition, which is required for ranking and running total functions.

The critical difference from GROUP BY: GROUP BY collapses multiple rows into one summary row. OVER keeps all rows intact and adds a computed column alongside each row. You can see the individual transactions AND the running total on every row simultaneously.

window_basics_over.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
-- Setup: monthly sales by salesperson
CREATE TABLE sales (
    sale_id    INT PRIMARY KEY,
    rep_name   VARCHAR(100),
    region     VARCHAR(50),
    sale_date  DATE,
    amount     DECIMAL(10,2)
);

-- GROUP BY collapses to one row per rep -- individual sales lost
SELECT rep_name, SUM(amount) AS total
FROM sales
GROUP BY rep_name;

-- Window function: all rows preserved + running total added
SELECT
    sale_id,
    rep_name,
    sale_date,
    amount,
    -- Running total per rep, ordered by date
    SUM(amount) OVER (
        PARTITION BY rep_name      -- separate window per rep
        ORDER BY sale_date         -- accumulate in date order
    ) AS running_total,
    -- Grand total for comparison (no PARTITION BY = whole table)
    SUM(amount) OVER () AS grand_total
FROM sales
ORDER BY rep_name, sale_date;
Output
sale_id | rep_name | sale_date | amount | running_total | grand_total
1 | Alice Tanaka| 2024-01-05 | 1200.00 | 1200.00 | 42850.00
4 | Alice Tanaka| 2024-01-12 | 850.00 | 2050.00 | 42850.00
7 | Alice Tanaka| 2024-02-03 | 1500.00 | 3550.00 | 42850.00
2 | Ben Okafor | 2024-01-08 | 2200.00 | 2200.00 | 42850.00
Window vs GROUP BY — The Core Difference
  • GROUP BY: N rows become 1 summary row — individual rows disappear
  • OVER(): N rows stay N rows — a computed column is added to each
  • PARTITION BY: divides the window into groups (like GROUP BY but without collapsing)
  • ORDER BY inside OVER: controls accumulation order for running totals and rankings
  • No PARTITION BY: window is the entire result set — dangerous on large tables
Production Insight
OVER() with no PARTITION BY = the entire table is one window = full table sort required.
On large tables always PARTITION BY a column with an index to split the sort into manageable chunks.
For EXPLAIN: look for WindowAgg node — the Sort node before it reveals whether the sort is index-backed or a filesort.
Key Takeaway
OVER() is what makes a function a window function — defines the rows it can see.
PARTITION BY splits into independent groups; ORDER BY inside OVER controls accumulation order.
No PARTITION BY on large tables = full table sort = production incident waiting to happen.

Ranking Functions — ROW_NUMBER, RANK, and DENSE_RANK

Three ranking functions, three subtly different behaviours when ties exist.

ROW_NUMBER() assigns a unique sequential integer to every row, even when rows are tied. The order assigned to tied rows is arbitrary (unless you add a tiebreaker to ORDER BY). Use ROW_NUMBER() when you need to pick exactly one row per group — for example, the most recent order per customer.

RANK() assigns the same number to tied rows, then skips numbers. If two rows tie for position 2, both get rank 2 and the next row gets rank 4. The gap represents the number of tied rows.

DENSE_RANK() also assigns the same number to ties, but does not skip. Two rows tied at position 2 both get 2, and the next row gets 3. Use DENSE_RANK() when you want contiguous rank values regardless of ties — 'what is the 3rd highest salary' works with DENSE_RANK, not RANK.

ranking_functions.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
-- Compare all three ranking functions on the same data
SELECT
    rep_name,
    region,
    SUM(amount)  AS total_sales,
    ROW_NUMBER() OVER (PARTITION BY region ORDER BY SUM(amount) DESC) AS row_num,
    RANK()       OVER (PARTITION BY region ORDER BY SUM(amount) DESC) AS rank_val,
    DENSE_RANK() OVER (PARTITION BY region ORDER BY SUM(amount) DESC) AS dense_rank_val
FROM sales
GROUP BY rep_name, region
ORDER BY region, total_sales DESC;

-- Common pattern: get the top 1 row per group
-- ROW_NUMBER guarantees exactly one row per region regardless of ties
WITH ranked_sales AS (
    SELECT *,
           ROW_NUMBER() OVER (PARTITION BY region ORDER BY amount DESC) AS rn
    FROM sales
)
SELECT rep_name, region, amount
FROM ranked_sales
WHERE rn = 1;  -- cannot use window function directly in WHERE -- must wrap in CTE
Output
rep_name | region | total_sales | row_num | rank_val | dense_rank_val
Alice Tanaka | East | 15200.00 | 1 | 1 | 1
Ben Okafor | East | 14800.00 | 2 | 2 | 2
Clara Webb | East | 14800.00 | 3 | 2 | 2 <- tied
Dana Kim | East | 12100.00 | 4 | 4 | 3 <- RANK gaps, DENSE_RANK does not
Window Functions Cannot Be Filtered in WHERE
SELECT , RANK() OVER (...) AS r FROM table WHERE r = 1 throws an error — window functions are computed after WHERE filtering, so the alias is not yet available. Wrap in a CTE: WITH ranked AS (SELECT , RANK() OVER (...) AS r FROM table) SELECT * FROM ranked WHERE r = 1.
Production Insight
ROW_NUMBER() is the correct function for 'get latest record per group' — use ORDER BY created_at DESC and filter WHERE rn = 1.
Random tie-breaking in ROW_NUMBER() can cause non-deterministic query results — always add a tiebreaker (usually the PK) to ORDER BY.
Wrap ranking in a CTE before filtering on the rank value — this is a universal pattern you will write dozens of times.
Key Takeaway
ROW_NUMBER = unique rank, RANK = gaps on ties, DENSE_RANK = no gaps on ties.
To get top-1 per group: ROW_NUMBER() in a CTE, filter WHERE rn = 1.
Always add a tiebreaker column to ORDER BY inside ROW_NUMBER() — arbitrary tie-breaking produces inconsistent results.

LAG and LEAD — Comparing Rows to Their Neighbours

LAG(column, n, default) returns the value from n rows before the current row within the window. LEAD(column, n, default) returns the value from n rows ahead. Both default n to 1 if omitted. The third argument sets what to return when there is no previous or next row (otherwise NULL).

These are indispensable for period-over-period analysis: month-over-month revenue change, day-over-day user growth, previous order date for a customer. Before LAG/LEAD existed, this required a self-join — and self-joins on large tables for row comparison are notoriously slow.

lag_lead_comparison.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
-- Month-over-month revenue change using LAG
WITH monthly_revenue AS (
    SELECT
        DATE_TRUNC('month', sale_date) AS month,
        SUM(amount)                    AS revenue
    FROM sales
    GROUP BY DATE_TRUNC('month', sale_date)
)
SELECT
    month,
    revenue,
    LAG(revenue, 1, 0) OVER (ORDER BY month) AS prev_month_revenue,
    revenue - LAG(revenue, 1, 0) OVER (ORDER BY month) AS change,
    ROUND(
        100.0 * (revenue - LAG(revenue) OVER (ORDER BY month))
        / NULLIF(LAG(revenue) OVER (ORDER BY month), 0),
    1) AS pct_change
FROM monthly_revenue
ORDER BY month;

-- Find customers whose next order came >90 days after the previous
-- LEAD gives us the next order date without a self-join
SELECT
    customer_id,
    order_date                                              AS this_order,
    LEAD(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) AS next_order,
    LEAD(order_date) OVER (PARTITION BY customer_id ORDER BY order_date)
        - order_date                                        AS days_to_next
FROM orders
HAVING days_to_next > 90 OR days_to_next IS NULL;
Output
month | revenue | prev_revenue | change | pct_change
2024-01-01 | 42850.00 | 0.00 | 42850.00 | NULL
2024-02-01 | 51200.00 | 42850.00 | 8350.00 | 19.5
2024-03-01 | 48900.00 | 51200.00 | -2300.00 | -4.5
NULLIF Prevents Division by Zero in Percentage Calculations
When computing percentage change, the previous value might be zero (first month of data, new customer). Dividing by zero throws an error or returns NULL depending on the database. NULLIF(prev_value, 0) returns NULL when prev_value is zero, making the division return NULL safely instead of crashing.
Production Insight
LAG/LEAD replaced thousands of correlated subqueries and self-joins in analytics codebases — if you see a self-join comparing a table to itself on adjacent rows, it is a LAG/LEAD rewrite opportunity.
Always provide a default value as the third argument (LAG(col, 1, 0)) — NULL arithmetic produces NULL results that silently break calculations.
For large tables, PARTITION BY customer_id + an index on (customer_id, order_date) is mandatory for acceptable performance.
Key Takeaway
LAG/LEAD access neighbouring rows without self-joins — the canonical tool for period-over-period comparisons.
Always supply a default (third argument) to avoid NULL arithmetic silently corrupting your calculations.
Add a composite index on (PARTITION_BY_col, ORDER_BY_col) — these queries require sorted access.

Window Frames — ROWS vs RANGE and the Subtle Default

The frame clause in an OVER definition controls exactly which rows are included in the window for each row's computation. This is the least understood part of window functions and the source of subtle bugs.

The default frame when you specify ORDER BY inside OVER is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — not ROWS. The RANGE default includes all rows that are tied on the ORDER BY value up to and including the current row. For dates with no duplicates this makes no visible difference. For financial data where multiple transactions share the same timestamp, the RANGE default groups all tied rows together — your running total will jump by the combined amount of all tied rows simultaneously, which is often not what you want.

ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW processes each physical row individually, regardless of ties. For running totals and moving averages, ROWS is almost always the correct choice.

window_frames.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
-- RANGE default vs ROWS explicit -- spot the difference
SELECT
    sale_date,
    amount,
    -- Default: RANGE -- ties share the same cumulative value
    SUM(amount) OVER (
        ORDER BY sale_date
        -- RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW (implicit default)
    ) AS running_range,
    -- Explicit ROWS: each physical row accumulated independently
    SUM(amount) OVER (
        ORDER BY sale_date
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS running_rows
FROM sales
WHERE rep_name = 'Alice Tanaka'
ORDER BY sale_date;

-- 7-day moving average: ROWS frame with sliding window
SELECT
    sale_date,
    amount,
    AVG(amount) OVER (
        ORDER BY sale_date
        ROWS BETWEEN 6 PRECEDING AND CURRENT ROW  -- current row + 6 before it
    ) AS moving_avg_7day
FROM daily_sales
ORDER BY sale_date;
Output
-- When two sales share the same date:
-- RANGE groups them: both rows show the combined total immediately
-- ROWS accumulates: first row shows partial, second row shows full
sale_date | amount | running_range | running_rows
2024-01-05 | 800.00 | 2200.00 | 800.00 <- RANGE jumps to combined total
2024-01-05 | 1400.00| 2200.00 | 2200.00 <- ROWS accumulates each row
The RANGE Default Causes Surprising Behaviour on Ties
When you write SUM(...) OVER (ORDER BY date), the default frame is RANGE which groups all rows with the same date value. If two transactions happen on the same day, both rows will show the combined daily total immediately — not the individual accumulation. For running totals, always specify ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW explicitly.
Production Insight
Running total queries in financial systems that show identical values for same-day transactions are almost always hitting the RANGE default rather than ROWS.
For moving averages, ROWS BETWEEN N PRECEDING AND CURRENT ROW is the correct frame — gives exactly N+1 rows of data regardless of value ties.
Specifying the frame explicitly (ROWS BETWEEN ...) is a production best practice — it documents intent and prevents subtle default-behaviour bugs.
Key Takeaway
Default frame with ORDER BY is RANGE — groups tied values, which is rarely what you want for running totals.
For running totals and moving averages, always specify ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW explicitly.
Explicit frame clauses are self-documenting and prevent subtle tie-handling bugs.
● Production incidentPOST-MORTEMseverity: high

A Window Function Over an Unpartitioned 50M-Row Table Brought the Reporting Database Down

Symptom
The reporting database CPU hit 100% and all other dashboard queries queued behind the blocking query. The analyst's query was still running after 12 minutes and had to be manually killed.
Assumption
Window functions are a database feature — the engine will handle them efficiently. The query looked clean and worked in development.
Root cause
The query used SUM(revenue) OVER (ORDER BY transaction_date) with no PARTITION BY clause, computing a running total across all 50 million rows ordered by date. Without PARTITION BY, the window function operates over the entire table as a single partition. The ORDER BY inside OVER requires sorting all 50 million rows. No index on transaction_date meant a filesort on the full dataset held an exclusive sort buffer for the duration.
Fix
Added PARTITION BY to split the computation into smaller groups (by region_id), added an index on (region_id, transaction_date) to support the window partition and ordering. Query time dropped from 12 minutes to 1.8 seconds.
Key lesson
  • Window functions without PARTITION BY operate over the entire table as one window — on large tables this is a full table sort
  • Add a composite index on (PARTITION_BY_col, ORDER_BY_col) to support window function execution
  • Test window function queries on production-scale row counts before deploying — performance does not extrapolate from sample data
Production debug guideDiagnosing slow queries involving window functions4 entries
Symptom · 01
Window function query is slow and EXPLAIN shows Sort or WindowAgg with high cost
Fix
Check if PARTITION BY has an index. Create a composite index on (partition_col, order_col): CREATE INDEX idx_sales_region_date ON sales(region_id, transaction_date). The index allows the engine to skip sorting entirely.
Symptom · 02
Cannot filter on a window function result in the WHERE clause
Fix
Window function values cannot be used in WHERE in the same SELECT. Wrap in a CTE or subquery: WITH ranked AS (SELECT , ROW_NUMBER() OVER (...) AS rn FROM table) SELECT FROM ranked WHERE rn = 1.
Symptom · 03
ROW_NUMBER() results are inconsistent between runs on tied values
Fix
ROW_NUMBER() assigns arbitrary order to ties. Add a tiebreaker column to the ORDER BY: ROW_NUMBER() OVER (ORDER BY created_at DESC, id DESC). The id column (PK) guarantees a deterministic result.
Symptom · 04
Running total resets unexpectedly at certain points
Fix
Your PARTITION BY is grouping data in a way you did not intend. Add PARTITION BY explicitly or remove it entirely. Check whether the ORDER BY inside OVER matches your intended calculation direction.
FunctionPurposeHandles TiesBest Used For
ROW_NUMBER()Unique sequential rankArbitrary order for tiesSelect exactly one row per group — latest record, deduplication
RANK()Rank with gaps on tiesTied rows share rank, next rank skipsCompetition rankings where tied positions matter
DENSE_RANK()Rank without gaps on tiesTied rows share rank, no skipsNth highest value queries — 'find the 3rd highest salary'
SUM/AVG/COUNT OVER()Running aggregatesDepends on frame clauseRunning totals, moving averages, period comparisons
LAG(col, n)Access n rows before currentn/aPeriod-over-period comparison, previous value reference
LEAD(col, n)Access n rows after currentn/aFuture value reference, days-to-next-event calculations
PERCENT_RANK()Relative percentile position 0–1Tied rows share rankDistribution analysis — bottom 10% queries with CTE filter
NTILE(n)Divide into n equal bucketsDistributes evenlyQuartile/decile analysis, A/B test bucket assignment

Key takeaways

1
Window functions compute values across related rows without collapsing them
the opposite of GROUP BY which removes individual rows.
2
ROW_NUMBER for deduplication, RANK for competition rankings, DENSE_RANK for Nth-highest queries.
3
LAG/LEAD replace expensive self-joins for row-neighbour comparisons
the canonical tool for period-over-period analysis.
4
The default frame with ORDER BY is RANGE
specify ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW explicitly for running totals to avoid tie-handling bugs.
5
PARTITION BY without an index causes a filesort on every execution
always add a composite index on (partition_col, order_col) for large tables.

Common mistakes to avoid

3 patterns
×

Trying to filter on a window function result in the same SELECT's WHERE clause

Symptom
ERROR: column 'rn' does not exist — or — window functions are not allowed in WHERE
Fix
Window functions are evaluated after WHERE. Wrap the window function in a CTE: WITH ranked AS (SELECT , ROW_NUMBER() OVER (...) AS rn FROM table) SELECT FROM ranked WHERE rn = 1.
×

Using OVER() without PARTITION BY on a large table

Symptom
Query hangs or times out — EXPLAIN shows Sort node on the full table row count, high actual time on the WindowAgg node
Fix
Add PARTITION BY to split the computation into smaller groups. Create a composite index on (partition_col, order_col) to allow index-backed sorting. Test window function queries against production-scale data before deploying.
×

Relying on RANGE default for running totals when data has duplicate ORDER BY values

Symptom
Multiple rows with the same date show the same running total value — transactions from the same day all show the combined daily total instead of individual accumulation
Fix
Specify the frame explicitly: SUM(amount) OVER (ORDER BY sale_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW). ROWS processes each physical row independently regardless of ties.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the difference between GROUP BY and a window function with PARTI...
Q02SENIOR
What is the difference between ROW_NUMBER(), RANK(), and DENSE_RANK()?
Q03SENIOR
Explain window function frames — what is the default frame and when does...
Q01 of 03JUNIOR

What is the difference between GROUP BY and a window function with PARTITION BY?

ANSWER
GROUP BY collapses multiple rows into a single summary row per group — the individual rows are gone from the result. A window function with PARTITION BY computes the aggregate within each partition but keeps every individual row in the output. The computed value appears as an additional column on each row. Example: GROUP BY rep_name returns one row per rep with a total. SUM(amount) OVER (PARTITION BY rep_name) returns every transaction with the rep's running total alongside each row. Use GROUP BY when you want summaries. Use window functions when you want derived values computed from a group but need the individual rows too.
FAQ · 3 QUESTIONS

Frequently Asked Questions

01
Can I use window functions in a WHERE clause?
02
What is the difference between SUM() OVER() and SUM() GROUP BY?
03
Are window functions supported in MySQL?
🔥

That's SQL Advanced. Mark it forged?

3 min read · try the examples if you haven't

Previous
SQL Transactions and ACID
6 / 16 · SQL Advanced
Next
SQL CTEs — Common Table Expressions