Fatskills
Practice. Master. Repeat.
Study Guide: TECH **SQL Window Functions for Data Analysis: Zero-Fluff, Hands-On Guide**
Source: https://www.fatskills.com/data-science/chapter/tech-sql-window-functions-for-data-analysis-zero-fluff-hands-on-guide

TECH **SQL Window Functions for Data Analysis: Zero-Fluff, Hands-On Guide**

By Fatskills Exam Guides Team — the exam nerds behind 28,500+ quizzes and 2.1M practice questions across 500+ global exams.

⏱️ ~7 min read

SQL Window Functions for Data Analysis: Zero-Fluff, Hands-On Guide

(ROW_NUMBER, RANK, DENSE_RANK, NTILE)


1. What This Is & Why It Matters

Window functions let you analyze data across rows without collapsing them—unlike GROUP BY, which squashes rows into aggregates. Think of them as a periscope that lets you peek at neighboring rows while keeping the full dataset intact.

Why this matters in production:
- Ranking: "Show me the top 3 sales reps per region" (without GROUP BY destroying the detail).
- Time-series gaps: "Flag missing days in a daily sales log" (without self-joins).
- Running totals: "Calculate cumulative revenue by month" (without recursive CTEs).
- Percentiles: "Identify the 90th percentile of order values" (without manual sorting).

Real-world scenario:
You inherit a dashboard that shows "Top 5 products by revenue per store." The current query uses a GROUP BY + subquery for each store, running in 12 seconds. Rewrite it with RANK()—it drops to 0.8 seconds and scales to 10,000 stores.


2. Core Concepts & Components

Function Definition Production Insight
ROW_NUMBER() Assigns a unique sequential integer to rows within a partition. ⚠️ Ties get arbitrary numbers—use only when uniqueness is guaranteed (e.g., timestamps).
RANK() Assigns ranks with gaps for ties (e.g., 1, 1, 3). Use for "Top N" reports where ties should share a rank (e.g., sports leaderboards).
DENSE_RANK() Assigns ranks without gaps (e.g., 1, 1, 2). Prefer over RANK() when you need consecutive ranks (e.g., customer tiering).
NTILE(n) Divides rows into n roughly equal buckets (e.g., quartiles). ⚠️ Buckets may not be perfectly equal—use for approximate segmentation (e.g., A/B testing).
OVER() Defines the "window" (partitioning + ordering) for the function. Always specify PARTITION BY and ORDER BY—omitting them defaults to the entire table.
PARTITION BY Groups rows into subsets (like GROUP BY, but rows aren’t collapsed). Critical for multi-dimensional analysis (e.g., "rank per region, per month").
ORDER BY Determines the sequence of rows within each partition. ⚠️ Window functions are order-dependent—wrong ordering = wrong results.
Frame Clause Defines a sliding window (e.g., ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING). Use for running totals or moving averages (e.g., "30-day rolling revenue").


3. Step-by-Step Hands-On: Ranking Sales Reps by Region


Prerequisites

  • A SQL environment (PostgreSQL, BigQuery, Snowflake, or SQL Server).
  • A table sales_reps with columns: rep_id, region, revenue, quarter.

Task

Write a query to: 1. Rank sales reps by revenue within each region.
2. Show only the top 2 reps per region.
3. Handle ties (e.g., two reps with $100K revenue).

Step 1: Inspect the Data

SELECT region, rep_id, revenue
FROM sales_reps
ORDER BY region, revenue DESC;

Expected output:


region   | rep_id | revenue
---------+--------+--------
East     | 101    | 150000
East     | 102    | 120000
East     | 103    | 120000  -- Tie
West     | 201    | 180000
West     | 202    | 160000
...

Step 2: Add RANK() with PARTITION BY

SELECT
  region,
  rep_id,
  revenue,
  RANK() OVER (PARTITION BY region ORDER BY revenue DESC) AS rank
FROM sales_reps;

Key:
- PARTITION BY region: Resets the rank for each region.
- ORDER BY revenue DESC: Ranks highest revenue first.

Output:


region | rep_id | revenue | rank
-------+--------+---------+------
East   | 101    | 150000  | 1
East   | 102    | 120000  | 2
East   | 103    | 120000  | 2  -- Tie
West   | 201    | 180000  | 1
West   | 202    | 160000  | 2

Step 3: Filter for Top 2 Reps

WITH ranked_reps AS (
  SELECT
region,
rep_id,
revenue,
RANK() OVER (PARTITION BY region ORDER BY revenue DESC) AS rank FROM sales_reps ) SELECT region, rep_id, revenue, rank FROM ranked_reps WHERE rank <= 2;

Why this works:
- The CTE (ranked_reps) calculates ranks first.
- The outer query filters for rank <= 2.

Step 4: Compare RANK() vs. DENSE_RANK()

SELECT
  region,
  rep_id,
  revenue,
  RANK() OVER (PARTITION BY region ORDER BY revenue DESC) AS rank,
  DENSE_RANK() OVER (PARTITION BY region ORDER BY revenue DESC) AS dense_rank
FROM sales_reps;

Output:


region | rep_id | revenue | rank | dense_rank
-------+--------+---------+------+-----------
East   | 101    | 150000  | 1    | 1
East   | 102    | 120000  | 2    | 2
East   | 103    | 120000  | 2    | 2
East   | 104    | 110000  | 4    | 3  -- Key difference!

Production insight:
- Use RANK() for "Top N" where gaps matter (e.g., "Top 3" should skip rank 4 if there’s a tie).
- Use DENSE_RANK() for consecutive numbering (e.g., customer tiers).


4. ? Production-Ready Best Practices


Performance

  • Index PARTITION BY and ORDER BY columns: Window functions scan partitions sequentially. Indexes speed this up.
    sql CREATE INDEX idx_sales_reps_region_revenue ON sales_reps (region, revenue DESC);
  • Avoid SELECT * with window functions: They process all rows, even if you only need a subset.
  • Use QUALIFY (Snowflake/BigQuery) or CTEs: Filter window results early.
    sql -- Snowflake/BigQuery SELECT region, rep_id, revenue FROM sales_reps QUALIFY RANK() OVER (PARTITION BY region ORDER BY revenue DESC) <= 2;

Readability

  • Name window functions clearly: sql RANK() OVER (PARTITION BY region ORDER BY revenue DESC) AS revenue_rank
  • Use CTEs for complex logic: sql WITH daily_sales AS (
    SELECT date, SUM(amount) AS total
    FROM orders
    GROUP BY date ), ranked_sales AS (
    SELECT
    date,
    total,
    RANK() OVER (ORDER BY total DESC) AS daily_rank
    FROM daily_sales ) SELECT * FROM ranked_sales WHERE daily_rank <= 5;

Edge Cases

  • Handle NULLs: Use COALESCE or NULLS LAST in ORDER BY.
    sql ORDER BY revenue DESC NULLS LAST
  • Partition size: Large partitions (e.g., 1M rows) can slow queries. Consider pre-aggregating.


5. ⚠️ Common Mistakes & Traps

Mistake Symptom Fix/Prevention
Omitting PARTITION BY Ranks are calculated across the entire table, not per group. Always ask: "Do I need to reset ranks for each group?" (e.g., per region, per month).
Forgetting ORDER BY Ranks are arbitrary (e.g., alphabetical). Window functions require ORDER BY to make sense.
Using ROW_NUMBER() for ties Ties get different ranks (e.g., 1, 2, 3 for three $100K reps). Use RANK() or DENSE_RANK() for ties.
Over-partitioning Too many small partitions (e.g., per customer). Pre-aggregate data or use QUALIFY to filter early.
Ignoring NULLs NULLs sort first (or last) unexpectedly. Explicitly handle NULLs with NULLS FIRST/LAST.


6. ? Exam/Certification Focus


Question Patterns

  1. "Top N per group":
  2. Question: "Show the top 2 products by sales per category."
  3. Trick: ROW_NUMBER() vs. RANK()—do ties matter?
  4. Answer: Use RANK() if ties should share a rank.

  5. Percentiles:

  6. Question: "Identify the 90th percentile of order values."
  7. Trick: NTILE(100) vs. PERCENT_RANK().
  8. Answer: NTILE(100) for approximate buckets; PERCENT_RANK() for exact percentiles.

  9. Running totals:

  10. Question: "Calculate cumulative revenue by month."
  11. Trick: Forgetting the frame clause (ROWS UNBOUNDED PRECEDING).
  12. Answer:
    sql
    SUM(revenue) OVER (ORDER BY month ROWS UNBOUNDED PRECEDING)

Key Distinctions

Function When to Use Exam Trap
ROW_NUMBER() Unique ranks (e.g., timestamps). ⚠️ Fails on ties—use only when uniqueness is guaranteed.
RANK() "Top N" with gaps (e.g., sports). ⚠️ Skips ranks after ties (e.g., 1, 1, 3).
DENSE_RANK() Consecutive ranks (e.g., tiers). ⚠️ No gaps (e.g., 1, 1, 2).
NTILE(n) Buckets (e.g., quartiles). ⚠️ Buckets may not be equal—use for approximate segmentation.


7. ? Hands-On Challenge


Challenge

You have a table employee_salaries with columns: department, employee_id, salary.
Write a query to: 1. Rank employees by salary within each department.
2. Show only employees in the top 25% of their department (use NTILE(4)).

Solution

WITH ranked_employees AS (
  SELECT
department,
employee_id,
salary,
NTILE(4) OVER (PARTITION BY department ORDER BY salary DESC) AS salary_quartile FROM employee_salaries ) SELECT department, employee_id, salary FROM ranked_employees WHERE salary_quartile = 1;

Why it works:
- NTILE(4) divides each department into 4 equal buckets (quartiles).
- salary_quartile = 1 filters for the top 25%.


8. ? Rapid-Reference Crib Sheet

Command/Concept Syntax/Example Notes
ROW_NUMBER() ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) ⚠️ No ties—assigns unique numbers.
RANK() RANK() OVER (PARTITION BY dept ORDER BY salary DESC) Gaps after ties (e.g., 1, 1, 3).
DENSE_RANK() DENSE_RANK() OVER (PARTITION BY dept ORDER BY salary DESC) No gaps (e.g., 1, 1, 2).
NTILE(n) NTILE(4) OVER (PARTITION BY dept ORDER BY salary DESC) ⚠️ Buckets may not be equal.
Frame Clause SUM(salary) OVER (ORDER BY date ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING) For running totals/moving averages.
PARTITION BY PARTITION BY region, month Resets the window for each group.
ORDER BY ORDER BY revenue DESC ⚠️ Required for ranking functions.
QUALIFY QUALIFY RANK() OVER (...) <= 2 Snowflake/BigQuery: Filter window results early.
NULL Handling ORDER BY salary DESC NULLS LAST ⚠️ Default NULL sorting varies by DB.


9. ? Where to Go Next

  1. PostgreSQL Window Functions Docs – Official guide with examples.
  2. BigQuery Window Functions – Google’s syntax and optimizations.
  3. Book: SQL for Data Analysis by O’Reilly – Chapter 5 covers window functions in depth.
  4. Practice: LeetCode SQL Problems – Filter for "window function" tags.


ADVERTISEMENT