Detective's Journal
SQL Window Functions Explained: The Complete Visual Guide (2026)
February 12, 2026
14 min read
Hristo Bogoev

SQL Window Functions Explained: The Complete Visual Guide (2026)

Master SQL window functions with visual examples. Learn ROW_NUMBER, RANK, LAG, LEAD, and more with before/after diagrams and interactive quizzes.

Window functions are the secret weapon that separates SQL beginners from SQL power users. They let you calculate running totals, compare rows to previous values, and rank records without the complexity of subqueries or self-joins. This guide covers everything you need to master them.

What Are SQL Window Functions?

Window functions perform calculations across a set of rows that are related to the current row. Unlike GROUP BY, which collapses rows into a single result, window functions keep all your rows intact while adding calculated columns.

Think of it as sliding a magnifying glass across your data. At each row, the function looks at a "window" of related rows and calculates a value. The OVER() clause defines what rows are in that window.

๐Ÿ“‹Original Data
๐Ÿ”Window Defined
โš™๏ธCalculation Applied
โœ…Result

Window functions preserve all rows while adding calculated columns

Here's the key difference between GROUP BY and window functions:

GROUP BY: Collapses rows

SELECT district, COUNT(*) as crime_count
FROM crime_scenes
GROUP BY district;

-- Result: One row per district
-- district    | crime_count
-- Downtown    | 15
-- Waterfront  | 8

Window Function: Keeps all rows

SELECT 
    case_id,
    district,
    COUNT(*) OVER(PARTITION BY district) as district_crime_count
FROM crime_scenes;

-- Result: Every row kept, count added as column
-- case_id | district    | district_crime_count
-- 001     | Downtown    | 15
-- 002     | Downtown    | 15
-- 003     | Waterfront  | 8

Every original row is preserved. The count is calculated for each row's partition.

The OVER() Clause: PARTITION BY and ORDER BY

The OVER() clause is the heart of every window function. It defines which rows belong to the window and in what order they're processed.

SUM(amount)SUM(amount)
Aggregate function to apply
OVEROVER (
Window function starts
PARTITION BYPARTITION BY district
Groups data into partitions
ORDER BYORDER BY crime_date)
Sets order within each partition

Color-coded breakdown of window function syntax

PARTITION BY divides your data into groups. It works like GROUP BY but without collapsing rows. If you omit it, the entire result set is treated as one partition.

ORDER BY determines the order of calculation within each partition. For running totals, this controls which rows are included as the function "slides" through the data.

Running total per district

SELECT 
    crime_date,
    district,
    reported_crimes,
    SUM(reported_crimes) OVER(
        PARTITION BY district 
        ORDER BY crime_date
    ) as running_total
FROM daily_crime_stats;

-- Result:
-- crime_date  | district   | reported_crimes | running_total
-- 2026-01-01  | Downtown   | 5               | 5
-- 2026-01-02  | Downtown   | 3               | 8
-- 2026-01-03  | Downtown   | 7               | 15
-- 2026-01-01  | Waterfront | 2               | 2
-- 2026-01-02  | Waterfront | 4               | 6

The running total resets for each district partition.

Want to practice PARTITION BY on real crime data? SQLNoir's detective cases use these patterns to analyze evidence across multiple tables.

Ranking Functions: ROW_NUMBER, RANK, and DENSE_RANK

The three ranking functions assign numbers to rows based on their position. The difference is how they handle ties. This is THE most common window function interview question.

FunctionTies BehaviorGap After TiesBest For
ROW_NUMBER()Ignores tiesN/APagination, unique IDs
RANK()Same rankYes (skips)Competition rankings
DENSE_RANK()Same rankNo gapsTop-N with ties

ROW_NUMBER vs RANK vs DENSE_RANK

All three functions on the same data

SELECT 
    suspect_name,
    prior_convictions,
    ROW_NUMBER() OVER(ORDER BY prior_convictions DESC) as row_num,
    RANK() OVER(ORDER BY prior_convictions DESC) as rank,
    DENSE_RANK() OVER(ORDER BY prior_convictions DESC) as dense_rank
FROM suspects;

-- Result (notice the ties at 5 convictions):
-- suspect_name | prior_convictions | row_num | rank | dense_rank
-- Mike         | 7                 | 1       | 1    | 1
-- Sara         | 5                 | 2       | 2    | 2
-- John         | 5                 | 3       | 2    | 2
-- Lisa         | 3                 | 4       | 4    | 3
-- Tom          | 1                 | 5       | 5    | 4

Sara and John both have 5 convictions. ROW_NUMBER assigns them 2 and 3. RANK gives both 2, then skips to 4. DENSE_RANK gives both 2, then continues to 3.

๐Ÿ•ต๏ธInterview Tip
When asked "What's the difference between RANK and DENSE_RANK?", always mention the gap behavior. RANK skips numbers after ties (1, 2, 2, 4). DENSE_RANK never has gaps (1, 2, 2, 3).

Aggregate Window Functions: SUM, AVG, COUNT

Standard aggregate functions gain new powers with OVER(). You can calculate running totals, moving averages, cumulative counts, and percentages of total.

โŒOriginal Data
detective | cases_solved
Mike      | 5
Sara      | 3
John      | 7
โœ…With Running Total
detective | cases_solved | running_total
Mike      | 5            | 5
Sara      | 3            | 8
John      | 7            | 15

SUM() OVER(ORDER BY) adds running totals

Running total and percentage of total

SELECT 
    detective,
    cases_solved,
    SUM(cases_solved) OVER(ORDER BY detective) as running_total,
    ROUND(
        100.0 * cases_solved / SUM(cases_solved) OVER(), 
        1
    ) as pct_of_total
FROM case_closures;

-- Result:
-- detective | cases_solved | running_total | pct_of_total
-- John      | 7            | 7             | 46.7
-- Mike      | 5            | 12            | 33.3
-- Sara      | 3            | 15            | 20.0

SUM() OVER() with no ORDER BY calculates the grand total for percentage calculations.

3-day moving average of crimes

SELECT 
    crime_date,
    daily_crimes,
    ROUND(
        AVG(daily_crimes) OVER(
            ORDER BY crime_date 
            ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
        ), 
        1
    ) as moving_avg_3day
FROM daily_crime_stats;

-- Result:
-- crime_date  | daily_crimes | moving_avg_3day
-- 2026-01-01  | 5            | 5.0
-- 2026-01-02  | 8            | 6.5
-- 2026-01-03  | 3            | 5.3
-- 2026-01-04  | 6            | 5.7

The frame ROWS BETWEEN 2 PRECEDING AND CURRENT ROW limits the average to the current row and two previous rows.

Time-Series Functions: LAG and LEAD

LAG looks backward to access previous row values. LEAD looks forward to access next row values. These are essential for month-over-month comparisons, day-over-day changes, and time-series analysis.

โŒMonthly Crime Data
month | crimes
Jan   | 45
Feb   | 52
Mar   | 38
โœ…With LAG Comparison
month | crimes | prev_month | change
Jan   | 45     | NULL       | NULL
Feb   | 52     | 45         | +7
Mar   | 38     | 52         | -14

LAG() enables month-over-month comparisons

Month-over-month crime comparison

SELECT 
    month,
    crimes,
    LAG(crimes, 1) OVER(ORDER BY month) as prev_month,
    crimes - LAG(crimes, 1) OVER(ORDER BY month) as change,
    LEAD(crimes, 1) OVER(ORDER BY month) as next_month
FROM monthly_stats;

-- LAG(crimes, 1) means "1 row before"
-- LAG(crimes, 2) would mean "2 rows before"
-- LEAD(crimes, 1) means "1 row ahead"

The optional second parameter is the offset (default 1). The optional third parameter is a default value for NULLs.

๐Ÿ•ต๏ธAvoid NULLs
Use LAG(column, 1, 0) to return 0 instead of NULL when there's no previous row. This prevents NULL contamination in calculations.
Case File #003๐Ÿ”

The Miami Marina Murder

โ—โ—โ—‹Intermediate

Think you understand LAG and time-series analysis? A body was found at Coral Bay Marina. Can you analyze surveillance timestamps to find gaps in the killer's alibi?

Start Investigation

NTILE and Distribution Functions

NTILE splits your data into a specified number of roughly equal buckets. NTILE(4) creates quartiles. NTILE(10) creates deciles. It handles uneven distributions automatically.

Split suspects into risk quartiles

SELECT 
    suspect_name,
    prior_convictions,
    NTILE(4) OVER(ORDER BY prior_convictions DESC) as risk_quartile
FROM suspects;

-- Result:
-- suspect_name | prior_convictions | risk_quartile
-- Mike         | 12                | 1  -- Top 25% (highest risk)
-- Sara         | 9                 | 1
-- John         | 7                 | 2
-- Lisa         | 5                 | 2
-- Tom          | 3                 | 3
-- Jane         | 2                 | 3
-- Bob          | 1                 | 4
-- Alice        | 0                 | 4  -- Bottom 25% (lowest risk)

Quartile 1 contains the top 25% of suspects by prior convictions.

๐Ÿ•ต๏ธNTILE for Segmentation
NTILE is underrated for quick segmentation. Use NTILE(5) to instantly identify your top 20% of anything. Customers, suspects, crime hotspots.

FIRST_VALUE, LAST_VALUE, and NTH_VALUE

These functions grab specific positional values from the window. FIRST_VALUE gets the first row's value. LAST_VALUE gets the last. NTH_VALUE gets any arbitrary position.

Get earliest and latest crime per district

SELECT 
    case_id,
    district,
    crime_date,
    FIRST_VALUE(crime_date) OVER(
        PARTITION BY district 
        ORDER BY crime_date
    ) as earliest_in_district,
    LAST_VALUE(crime_date) OVER(
        PARTITION BY district 
        ORDER BY crime_date
        ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
    ) as latest_in_district
FROM crime_reports;

Notice the explicit frame for LAST_VALUE. Without it, you get unexpected results.

โš ๏ธLAST_VALUE Gotcha
By default, the window frame only includes rows up to the current row. Add ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING to include all rows and get the true last value.

Window Frames: ROWS BETWEEN and RANGE BETWEEN

Window frames let you specify exactly which rows are included in the calculation. This is how you build moving averages, trailing sums, and other sliding window calculations.

โฎ๏ธUNBOUNDED PRECEDING
โฌ…๏ธN PRECEDING
๐Ÿ“CURRENT ROW
โžก๏ธN FOLLOWING
โญ๏ธUNBOUNDED FOLLOWING

Frame boundary options: from first row to last row

7-day moving average

SELECT 
    crime_date,
    daily_crimes,
    ROUND(
        AVG(daily_crimes) OVER(
            ORDER BY crime_date 
            ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
        ),
        1
    ) as moving_avg_7day
FROM daily_crime_stats;

-- ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
-- includes the current row + 6 previous rows = 7 days

ROWS BETWEEN counts physical rows. RANGE BETWEEN uses logical value ranges (useful for date gaps).

Frame TypeHow It WorksUse Case
ROWS BETWEENPhysical row countMoving averages (N rows back)
RANGE BETWEENLogical value rangeDate ranges with gaps
UNBOUNDED PRECEDINGFrom first rowRunning totals from start
UNBOUNDED FOLLOWINGTo last rowReverse running totals

Window frame options

Choosing the Right Window Function

With so many window functions available, how do you know which one to use? Here's a quick decision guide.

๐Ÿค”What do you need?
๐Ÿ†Ranking โ†’ RANK/DENSE_RANK/ROW_NUMBER
โ†”๏ธCompare rows โ†’ LAG/LEAD
โž•Running total โ†’ SUM OVER(ORDER BY)
๐Ÿ“ŠBuckets โ†’ NTILE(n)

Decision flowchart for choosing window functions

NeedFunctionExample Use Case
Unique sequential numbersROW_NUMBER()Pagination, deduplication
Rank with tiesRANK() / DENSE_RANK()Leaderboards, top-N
Previous/next valueLAG() / LEAD()Month-over-month, day-over-day
Running totalSUM() OVER(ORDER BY)Cumulative sales, balance
Moving averageAVG() with frame7-day average, smoothing
Percentile bucketsNTILE(n)Top 25%, quartiles
First/last in groupFIRST_VALUE / LAST_VALUEEarliest, latest records

Quick reference: Window function selection

Ready to put window functions to work?

SQLNoir's intermediate and advanced cases require exactly these patterns. Rank suspects, analyze time sequences, find patterns across partitions.

Start Your Investigation โ†’

Common Mistakes and How to Avoid Them

Window functions have some tricky gotchas. Here are the mistakes that trip up most learners.

1. Using window functions in WHERE (won't work)

โŒWrong: Window in WHERE
SELECT * FROM suspects
WHERE ROW_NUMBER() OVER(ORDER BY risk) = 1

โ€ขWindow functions can't be used in WHERE

โ€ขWill throw an error

โœ…Correct: Use CTE/Subquery
WITH ranked AS (
  SELECT *, 
    ROW_NUMBER() OVER(ORDER BY risk) AS rn
  FROM suspects
)
SELECT * FROM ranked WHERE rn = 1

โ€ขWrap in CTE first

โ€ขFilter on the alias in outer query

Window functions are evaluated after WHERE. Wrap in a CTE first.

2. Forgetting ORDER BY in ranking functions

โŒUndefined Order
ROW_NUMBER() OVER()

โ€ขWithout ORDER BY, row numbers are arbitrary

โ€ขResults change between runs

โœ…Deterministic Order
ROW_NUMBER() OVER(ORDER BY id)

โ€ขAlways specify ORDER BY for ranking

โ€ขResults are consistent

Always specify ORDER BY for deterministic ranking

3. LAST_VALUE without proper frame

โŒWrong: Returns Current Row
LAST_VALUE(name) OVER(
  ORDER BY date
)

โ€ขDefault frame ends at current row

โ€ขLAST_VALUE returns the current row, not the actual last

โœ…Correct: Full Frame
LAST_VALUE(name) OVER(
  ORDER BY date
  ROWS BETWEEN UNBOUNDED PRECEDING 
    AND UNBOUNDED FOLLOWING
)

โ€ขExplicit frame includes all rows

โ€ขReturns the true last value

LAST_VALUE needs an explicit frame to work as expected

๐Ÿ”Key Insight
Window functions are evaluated AFTER WHERE, GROUP BY, and HAVING, but BEFORE ORDER BY and LIMIT. That's why you can't filter on them directly.

Window Functions Interview Questions

These patterns come up constantly in SQL interviews. Master them and you'll handle most window function questions with confidence.

PatternFunctionKey Technique
Top N per groupROW_NUMBER()PARTITION BY + filter rn <= N
Running totalSUM() OVER(ORDER BY)Cumulative with ORDER BY
Month-over-monthLAG()Compare current to previous
DeduplicationROW_NUMBER()Keep row where rn = 1
Percentile rankingNTILE(100) or PERCENT_RANK()Bucket into percentages

Common interview patterns

Classic: Top 3 suspects per district

WITH ranked_suspects AS (
    SELECT 
        suspect_name,
        district,
        risk_score,
        ROW_NUMBER() OVER(
            PARTITION BY district 
            ORDER BY risk_score DESC
        ) as rank_in_district
    FROM suspects
)
SELECT *
FROM ranked_suspects
WHERE rank_in_district <= 3;

-- Returns top 3 highest-risk suspects for each district

This "top N per group" pattern appears in almost every SQL interview.

๐Ÿ” Detectiveโ€™s Quiz

Q1.Which function gives unique numbers even for tied values?

Q2.What does LAG(sales, 2) return?

Q3.You want to filter on ROW_NUMBER() = 1. What do you need to do?

Q4.NTILE(4) creates what?

FAQ

What are window functions in SQL?

Functions that perform calculations across a set of rows related to the current row, without collapsing rows like GROUP BY. They use OVER() to define the window of rows to operate on.

What is the difference between ROW_NUMBER, RANK, and DENSE_RANK?

ROW_NUMBER gives unique numbers (1,2,3,4) even for ties. RANK gives same rank for ties but skips numbers after (1,2,2,4). DENSE_RANK gives same rank for ties without gaps (1,2,2,3).

Can you use window functions in a WHERE clause?

No. Window functions are evaluated after WHERE. Wrap your query in a CTE or subquery first, then filter on the window function alias in the outer query.

What is PARTITION BY in window functions?

PARTITION BY divides the result set into groups (partitions) for the window function to operate on. It's like GROUP BY but doesn't collapse rows.

When should I use window functions instead of GROUP BY?

Use window functions when you need to keep all rows while adding calculated columns (running totals, rankings, comparisons). Use GROUP BY when you want to collapse rows into summary statistics.

Conclusion

Window functions transform what's possible with SQL. Running totals, rankings, time-series comparisons, and percentile analysis all become straightforward once you understand OVER(), PARTITION BY, and ORDER BY.

The key patterns to remember: use ROW_NUMBER for unique numbering and deduplication, RANK/DENSE_RANK for leaderboards, LAG/LEAD for comparing rows, and SUM/AVG with frames for running and moving calculations.

Practice these patterns and they'll become second nature. Window functions are one of those SQL skills that, once learned, you'll use constantly.

Ready to start your next investigation?

Jump into the SQLNoir case files and put these tips to work.