Wicked Smart Data
LearnInsightsAboutContact
Sign InLet's Build
LearnInsightsAboutContact
Sign InLet's Build
Wicked Smart Data

Intelligence, automation, and expert execution — plus an elite library of free knowledge. We turn complexity into competitive advantage.

Start a conversation

Platform

  • Learning Paths
  • Insights
  • RSS Feed

Company

  • About
  • Contact
  • Work With Us

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Wicked Smart Data. All rights reserved.

Intelligence · Automation · Advantage

All Insights
SQL

Window Functions: RANK, ROW_NUMBER, and LAG

⚡ Practitioner18 min readMar 23, 2026Updated Aug 29, 2026
Hero image for Window Functions: RANK, ROW_NUMBER, and LAG
On this page
  • Prerequisites
  • Understanding Window Functions vs Traditional Approaches
  • RANK vs ROW_NUMBER: Handling Ties Like a Pro
  • When to Use Each Function
  • LAG and LEAD: Time Series Analysis Made Simple
  • Basic LAG Usage for Period-over-Period Analysis
  • Advanced LAG Patterns: Multiple Periods and Default Values
  • LEAD for Forward-Looking Analysis
  • Combining LAG with Conditional Logic for Trend Analysis
  • Advanced Partitioning Strategies
  • Multi-Level Partitioning
  • Dynamic Partitioning for Comparative Analysis
  • Conditional Partitioning
  • Performance Optimization for Window Functions
  • Index Strategy
  • Limiting Window Function Scope
  • Reusing Window Specifications
  • Hands-On Exercise: E-commerce Performance Dashboard
  • Setup
  • Your Task
  • Requirements
  • Starter Code Structure
  • Solution
  • Common Mistakes & Troubleshooting
  • Mistake 1: Using Window Functions in WHERE Clauses
  • Mistake 2: Forgetting to Handle NULLs in LAG Calculations
  • Mistake 3: Incorrect Partitioning Leading to Wrong Results
  • Mistake 4: ROW_NUMBER with Non-Deterministic Ordering
  • Mistake 5: Inefficient Window Function Usage
  • Summary & Next Steps
  • Logical Next Steps
  • Master SQL Window Functions: RANK, ROW_NUMBER, and LAG in Real-World Scenarios

    Picture this: You're analyzing e-commerce performance data and need to identify the top 3 products by revenue in each category, find the best-performing month for each product, and calculate month-over-month growth rates. Your first instinct might be to write multiple queries with subqueries and self-joins, creating a mess of complex SQL that's hard to read and maintain.

    There's a better way. Window functions let you perform these calculations elegantly in a single query, computing rankings, running totals, and period-over-period comparisons without the complexity of traditional grouping approaches. They're the difference between amateur and professional-level SQL.

    By the end of this lesson, you'll wield three of the most powerful window functions with confidence, turning complex analytical questions into clean, readable SQL.

    What you'll learn:

    • How RANK and ROW_NUMBER differ and when to use each for realistic ranking scenarios
    • Techniques for handling ties in rankings using DENSE_RANK vs RANK
    • LAG and LEAD patterns for period-over-period analysis and trend detection
    • Advanced partitioning strategies to segment your analysis by business dimensions
    • Performance optimization techniques for window functions in large datasets

    Prerequisites

    You should be comfortable with basic SQL queries including SELECT, WHERE, GROUP BY, and ORDER BY. Familiarity with JOINs and subqueries will help, though we'll show you how window functions often eliminate the need for complex subquery patterns.

    We'll use PostgreSQL syntax throughout, but these concepts apply to all modern databases (SQL Server, MySQL 8.0+, Oracle, etc.) with minor syntax variations.

    Understanding Window Functions vs Traditional Approaches

    Before diving into specific functions, let's establish what makes window functions special. Consider this common scenario: you need the top 2 sales representatives by revenue in each region from a sales table.

    Here's the traditional approach with subqueries:

    -- Traditional approach: complex and hard to read
    SELECT s.rep_name, s.region, s.total_revenue
    FROM (
        SELECT rep_name, region, total_revenue,
               (SELECT COUNT(*) 
                FROM sales s2 
                WHERE s2.region = s1.region 
                AND s2.total_revenue > s1.total_revenue) + 1 as rank_num
        FROM sales s1
    ) s
    WHERE s.rank_num <= 2
    ORDER BY s.region, s.rank_num;
    

    Now with window functions:

    -- Window function approach: clean and intuitive
    SELECT rep_name, region, total_revenue,
           RANK() OVER (PARTITION BY region ORDER BY total_revenue DESC) as revenue_rank
    FROM sales
    WHERE revenue_rank <= 2
    ORDER BY region, revenue_rank;
    

    Wait—that second query won't work. Here's why window functions are tricky: you can't use the window function result in the WHERE clause directly. The window function is evaluated after the WHERE clause. Here's the correct approach:

    -- Correct window function approach
    WITH ranked_sales AS (
        SELECT rep_name, region, total_revenue,
               RANK() OVER (PARTITION BY region ORDER BY total_revenue DESC) as revenue_rank
        FROM sales
    )
    SELECT rep_name, region, total_revenue, revenue_rank
    FROM ranked_sales
    WHERE revenue_rank <= 2
    ORDER BY region, revenue_rank;
    

    This pattern—using a CTE (Common Table Expression) to apply the window function, then filtering the results—is fundamental to working with window functions effectively.

    RANK vs ROW_NUMBER: Handling Ties Like a Pro

    Let's work with a realistic dataset: monthly product sales data where ties frequently occur.

    -- Sample data setup
    CREATE TABLE monthly_sales AS
    SELECT * FROM (VALUES
        ('2023-01', 'Laptop Pro', 'Electronics', 15000),
        ('2023-01', 'Office Chair', 'Furniture', 8000),
        ('2023-01', 'Desk Lamp', 'Furniture', 3000),
        ('2023-01', 'Smartphone', 'Electronics', 15000), -- tied with Laptop
        ('2023-02', 'Laptop Pro', 'Electronics', 18000),
        ('2023-02', 'Office Chair', 'Furniture', 8000), -- same as previous month
        ('2023-02', 'Desk Lamp', 'Furniture', 3500),
        ('2023-02', 'Smartphone', 'Electronics', 14000)
    ) AS t(month, product_name, category, revenue);
    

    Now let's see how RANK and ROW_NUMBER handle ties differently:

    SELECT month, product_name, category, revenue,
           RANK() OVER (PARTITION BY month ORDER BY revenue DESC) as rank_with_ties,
           ROW_NUMBER() OVER (PARTITION BY month ORDER BY revenue DESC) as row_number,
           DENSE_RANK() OVER (PARTITION BY month ORDER BY revenue DESC) as dense_rank
    FROM monthly_sales
    ORDER BY month, revenue DESC;
    

    Results:

    month   | product_name | category    | revenue | rank_with_ties | row_number | dense_rank
    --------|-------------|-------------|---------|----------------|------------|------------
    2023-01 | Laptop Pro  | Electronics |   15000 |              1 |          1 |          1
    2023-01 | Smartphone  | Electronics |   15000 |              1 |          2 |          1
    2023-01 | Office Chair| Furniture   |    8000 |              3 |          3 |          2
    2023-01 | Desk Lamp   | Furniture   |    3000 |              4 |          4 |          3
    2023-02 | Laptop Pro  | Electronics |   18000 |              1 |          1 |          1
    2023-02 | Smartphone  | Electronics |   14000 |              2 |          2 |          2
    2023-02 | Office Chair| Furniture   |    8000 |              3 |          3 |          3
    2023-02 | Desk Lamp   | Furniture   |    3500 |              4 |          4 |          4
    

    Notice the key differences:

    • RANK(): Both tied products get rank 1, then the next rank is 3 (skipping 2)
    • ROW_NUMBER(): Assigns unique sequential numbers even for ties (the order for ties depends on the database's internal ordering)
    • DENSE_RANK(): Both tied products get rank 1, but the next rank is 2 (no gaps)

    When to Use Each Function

    Use RANK() when you need traditional competitive ranking (like Olympic medals):

    -- Find products that rank in top 3, accounting for ties properly
    WITH product_rankings AS (
        SELECT month, product_name, revenue,
               RANK() OVER (PARTITION BY month ORDER BY revenue DESC) as revenue_rank
        FROM monthly_sales
    )
    SELECT *
    FROM product_rankings
    WHERE revenue_rank <= 3;
    -- If two products tie for #1, both get rank 1, and the next is rank 3
    

    Use ROW_NUMBER() when you need exactly N items regardless of ties:

    -- Select exactly 2 products per month, breaking ties arbitrarily
    WITH product_selection AS (
        SELECT month, product_name, revenue,
               ROW_NUMBER() OVER (PARTITION BY month ORDER BY revenue DESC, product_name) as rn
        FROM monthly_sales
    )
    SELECT *
    FROM product_selection
    WHERE rn <= 2;
    -- Always returns exactly 2 products per month
    

    Use DENSE_RANK() when gaps in ranking don't make sense:

    -- Create performance tiers without gaps
    SELECT month, product_name, revenue,
           CASE 
               WHEN DENSE_RANK() OVER (PARTITION BY month ORDER BY revenue DESC) = 1 THEN 'Tier 1'
               WHEN DENSE_RANK() OVER (PARTITION BY month ORDER BY revenue DESC) = 2 THEN 'Tier 2'
               ELSE 'Tier 3'
           END as performance_tier
    FROM monthly_sales;
    

    Pro Tip

    When using ROW_NUMBER() to break ties, always include additional columns in your ORDER BY clause to make the tie-breaking deterministic. Otherwise, you might get different results between query runs.

    LAG and LEAD: Time Series Analysis Made Simple

    LAG and LEAD functions access data from other rows relative to the current row. They're perfect for period-over-period comparisons, trend analysis, and identifying changes over time.

    Let's expand our dataset with more temporal data:

    CREATE TABLE quarterly_revenue AS
    SELECT * FROM (VALUES
        ('Q1-2022', 'Laptop Pro', 120000),
        ('Q2-2022', 'Laptop Pro', 135000),
        ('Q3-2022', 'Laptop Pro', 128000),
        ('Q4-2022', 'Laptop Pro', 155000),
        ('Q1-2023', 'Laptop Pro', 148000),
        ('Q2-2023', 'Laptop Pro', 162000),
        ('Q1-2022', 'Smartphone', 200000),
        ('Q2-2022', 'Smartphone', 185000),
        ('Q3-2022', 'Smartphone', 195000),
        ('Q4-2022', 'Smartphone', 220000),
        ('Q1-2023', 'Smartphone', 210000),
        ('Q2-2023', 'Smartphone', 235000)
    ) AS t(quarter, product_name, revenue);
    

    Basic LAG Usage for Period-over-Period Analysis

    SELECT quarter, product_name, revenue,
           LAG(revenue) OVER (PARTITION BY product_name ORDER BY quarter) as previous_quarter_revenue,
           revenue - LAG(revenue) OVER (PARTITION BY product_name ORDER BY quarter) as quarter_over_quarter_change,
           ROUND(
               (revenue - LAG(revenue) OVER (PARTITION BY product_name ORDER BY quarter)) * 100.0 / 
               LAG(revenue) OVER (PARTITION BY product_name ORDER BY quarter), 2
           ) as pct_change
    FROM quarterly_revenue
    ORDER BY product_name, quarter;
    

    Results:

    quarter  | product_name | revenue | previous_quarter_revenue | quarter_over_quarter_change | pct_change
    ---------|--------------|---------|--------------------------|----------------------------|------------
    Q1-2022  | Laptop Pro   |  120000 |                     NULL |                       NULL |       NULL
    Q2-2022  | Laptop Pro   |  135000 |                   120000 |                      15000 |      12.50
    Q3-2022  | Laptop Pro   |  128000 |                   135000 |                      -7000 |      -5.19
    Q4-2022  | Laptop Pro   |  155000 |                   128000 |                      27000 |      21.09
    Q1-2023  | Laptop Pro   |  148000 |                   155000 |                      -7000 |      -4.52
    Q2-2023  | Laptop Pro   |  162000 |                   148000 |                      14000 |       9.46
    

    Notice how LAG returns NULL for the first row in each partition—there's no previous value to reference.

    Advanced LAG Patterns: Multiple Periods and Default Values

    You can look back multiple periods and provide default values:

    -- Compare current quarter to same quarter last year
    SELECT quarter, product_name, revenue,
           LAG(revenue, 4, 0) OVER (PARTITION BY product_name ORDER BY quarter) as same_quarter_last_year,
           revenue - LAG(revenue, 4, 0) OVER (PARTITION BY product_name ORDER BY quarter) as year_over_year_change
    FROM quarterly_revenue
    ORDER BY product_name, quarter;
    

    The second parameter (4) means "look back 4 rows," and the third parameter (0) is the default value when no previous row exists.

    LEAD for Forward-Looking Analysis

    LEAD works the same way but looks forward:

    -- Identify quarters where revenue will decline next quarter
    SELECT quarter, product_name, revenue,
           LEAD(revenue) OVER (PARTITION BY product_name ORDER BY quarter) as next_quarter_revenue,
           CASE 
               WHEN LEAD(revenue) OVER (PARTITION BY product_name ORDER BY quarter) < revenue 
               THEN 'Decline Expected'
               WHEN LEAD(revenue) OVER (PARTITION BY product_name ORDER BY quarter) > revenue 
               THEN 'Growth Expected'
               ELSE 'Unknown'
           END as trend_prediction
    FROM quarterly_revenue
    ORDER BY product_name, quarter;
    

    Combining LAG with Conditional Logic for Trend Analysis

    Here's a more sophisticated example that identifies trend patterns:

    WITH trend_analysis AS (
        SELECT quarter, product_name, revenue,
               LAG(revenue, 1) OVER (PARTITION BY product_name ORDER BY quarter) as prev_1,
               LAG(revenue, 2) OVER (PARTITION BY product_name ORDER BY quarter) as prev_2,
               LAG(revenue, 3) OVER (PARTITION BY product_name ORDER BY quarter) as prev_3
        FROM quarterly_revenue
    )
    SELECT quarter, product_name, revenue,
           CASE 
               WHEN revenue > prev_1 AND prev_1 > prev_2 AND prev_2 > prev_3 THEN 'Accelerating Growth'
               WHEN revenue > prev_1 AND prev_1 > prev_2 THEN 'Growing'
               WHEN revenue < prev_1 AND prev_1 < prev_2 THEN 'Declining'
               WHEN revenue < prev_1 AND prev_1 < prev_2 AND prev_2 < prev_3 THEN 'Steep Decline'
               ELSE 'Volatile'
           END as trend_pattern
    FROM trend_analysis
    WHERE prev_3 IS NOT NULL  -- Only analyze quarters with sufficient history
    ORDER BY product_name, quarter;
    

    Advanced Partitioning Strategies

    So far, we've used simple partitioning (by product, by month). But real-world analysis often requires more sophisticated partitioning strategies.

    Multi-Level Partitioning

    -- Sample data: sales by rep, region, and month
    CREATE TABLE sales_performance AS
    SELECT * FROM (VALUES
        ('2023-01', 'North', 'Alice Johnson', 45000),
        ('2023-01', 'North', 'Bob Smith', 38000),
        ('2023-01', 'South', 'Carol Davis', 52000),
        ('2023-01', 'South', 'Dave Wilson', 41000),
        ('2023-02', 'North', 'Alice Johnson', 48000),
        ('2023-02', 'North', 'Bob Smith', 42000),
        ('2023-02', 'South', 'Carol Davis', 49000),
        ('2023-02', 'South', 'Dave Wilson', 44000),
        ('2023-03', 'North', 'Alice Johnson', 51000),
        ('2023-03', 'North', 'Bob Smith', 39000),
        ('2023-03', 'South', 'Carol Davis', 55000),
        ('2023-03', 'South', 'Dave Wilson', 47000)
    ) AS t(month, region, rep_name, sales);
    
    -- Rank reps within their region for each month
    SELECT month, region, rep_name, sales,
           RANK() OVER (PARTITION BY month, region ORDER BY sales DESC) as regional_rank,
           RANK() OVER (PARTITION BY month ORDER BY sales DESC) as overall_rank
    FROM sales_performance
    ORDER BY month, region, regional_rank;
    

    Dynamic Partitioning for Comparative Analysis

    Sometimes you need different partitioning strategies in the same query:

    -- Compare each rep to regional peers and track individual performance over time
    SELECT month, region, rep_name, sales,
           -- Regional ranking each month
           RANK() OVER (PARTITION BY month, region ORDER BY sales DESC) as monthly_regional_rank,
           -- Individual performance tracking
           LAG(sales) OVER (PARTITION BY rep_name ORDER BY month) as prev_month_sales,
           sales - LAG(sales) OVER (PARTITION BY rep_name ORDER BY month) as month_over_month,
           -- Regional context
           AVG(sales) OVER (PARTITION BY month, region) as regional_avg_sales,
           sales - AVG(sales) OVER (PARTITION BY month, region) as vs_regional_avg
    FROM sales_performance
    ORDER BY month, region, monthly_regional_rank;
    

    Conditional Partitioning

    You can even create partitions based on data characteristics:

    -- Analyze high performers (>45k) separately from others
    SELECT month, rep_name, sales,
           CASE WHEN sales > 45000 THEN 'High Performer' ELSE 'Standard Performer' END as performance_tier,
           RANK() OVER (
               PARTITION BY month, 
                           CASE WHEN sales > 45000 THEN 'High' ELSE 'Standard' END 
               ORDER BY sales DESC
           ) as tier_rank
    FROM sales_performance
    ORDER BY month, performance_tier, tier_rank;
    

    Performance Optimization for Window Functions

    Window functions can be expensive on large datasets. Here's how to optimize them:

    Index Strategy

    -- For queries partitioning by region and ordering by date
    CREATE INDEX idx_sales_region_date ON sales_performance (region, month);
    
    -- For individual time series analysis
    CREATE INDEX idx_sales_rep_date ON sales_performance (rep_name, month);
    

    Limiting Window Function Scope

    Instead of calculating window functions on entire tables, limit the dataset first:

    -- Inefficient: window function on full table
    SELECT rep_name, sales, 
           RANK() OVER (ORDER BY sales DESC) as sales_rank
    FROM large_sales_table;  -- millions of rows
    
    -- Efficient: filter first, then apply window function
    WITH recent_sales AS (
        SELECT rep_name, sales
        FROM large_sales_table
        WHERE month >= '2023-01'  -- much smaller dataset
    )
    SELECT rep_name, sales,
           RANK() OVER (ORDER BY sales DESC) as sales_rank
    FROM recent_sales;
    

    Reusing Window Specifications

    When using the same window specification multiple times, define it once:

    -- Instead of repeating the window specification
    SELECT month, rep_name, sales,
           RANK() OVER (PARTITION BY month ORDER BY sales DESC),
           DENSE_RANK() OVER (PARTITION BY month ORDER BY sales DESC),
           ROW_NUMBER() OVER (PARTITION BY month ORDER BY sales DESC)
    FROM sales_performance;
    
    -- Define the window once
    SELECT month, rep_name, sales,
           RANK() OVER monthly_sales_window,
           DENSE_RANK() OVER monthly_sales_window,
           ROW_NUMBER() OVER monthly_sales_window
    FROM sales_performance
    WINDOW monthly_sales_window AS (PARTITION BY month ORDER BY sales DESC);
    

    Hands-On Exercise: E-commerce Performance Dashboard

    Let's build a comprehensive sales performance analysis that combines all the techniques we've covered. You'll create a dashboard-style query that provides multiple insights from a single dataset.

    Setup

    First, create this sample e-commerce dataset:

    CREATE TABLE ecommerce_sales AS
    SELECT * FROM (VALUES
        ('2023-01', 'Electronics', 'Laptop Pro', 'Alice', 25000),
        ('2023-01', 'Electronics', 'Smartphone X', 'Bob', 18000),
        ('2023-01', 'Electronics', 'Tablet Plus', 'Carol', 12000),
        ('2023-01', 'Books', 'Data Science Guide', 'Alice', 8000),
        ('2023-01', 'Books', 'SQL Mastery', 'Dave', 6000),
        ('2023-01', 'Home', 'Smart Speaker', 'Eve', 15000),
        ('2023-02', 'Electronics', 'Laptop Pro', 'Alice', 28000),
        ('2023-02', 'Electronics', 'Smartphone X', 'Bob', 19500),
        ('2023-02', 'Electronics', 'Tablet Plus', 'Carol', 11000),
        ('2023-02', 'Books', 'Data Science Guide', 'Alice', 8500),
        ('2023-02', 'Books', 'SQL Mastery', 'Dave', 7200),
        ('2023-02', 'Home', 'Smart Speaker', 'Eve', 16500),
        ('2023-03', 'Electronics', 'Laptop Pro', 'Alice', 32000),
        ('2023-03', 'Electronics', 'Smartphone X', 'Bob', 21000),
        ('2023-03', 'Electronics', 'Tablet Plus', 'Carol', 10500),
        ('2023-03', 'Books', 'Data Science Guide', 'Alice', 9200),
        ('2023-03', 'Books', 'SQL Mastery', 'Dave', 7800),
        ('2023-03', 'Home', 'Smart Speaker', 'Eve', 14000)
    ) AS t(month, category, product, sales_rep, revenue);
    

    Your Task

    Create a comprehensive analysis query that shows:

    1. Each product's monthly ranking within its category
    2. Month-over-month growth for each product
    3. Each sales rep's overall ranking for the month
    4. Whether each product is the top performer in its category for that month
    5. The performance trend (improving, declining, stable) for each product

    Requirements

    • Use appropriate window functions for each calculation
    • Handle NULL values properly in your LAG calculations
    • Include meaningful column names
    • Order results logically for a dashboard presentation

    Starter Code Structure

    WITH base_analysis AS (
        -- Your window function calculations here
        SELECT month, category, product, sales_rep, revenue,
               -- Add your window functions
        FROM ecommerce_sales
    ),
    trend_analysis AS (
        -- Add trend calculations here
        SELECT *,
               -- Calculate trends based on previous calculations
        FROM base_analysis
    )
    SELECT -- Final select with clean column names and logical ordering
    FROM trend_analysis
    ORDER BY -- Choose appropriate ordering
    

    Solution

    Here's a complete solution that demonstrates all the concepts we've covered:

    WITH base_analysis AS (
        SELECT month, category, product, sales_rep, revenue,
               -- Product rankings within category each month
               RANK() OVER (PARTITION BY month, category ORDER BY revenue DESC) as category_rank,
               -- Sales rep overall ranking each month
               DENSE_RANK() OVER (PARTITION BY month ORDER BY revenue DESC) as rep_overall_rank,
               -- Previous month revenue for growth calculations
               LAG(revenue) OVER (PARTITION BY product ORDER BY month) as prev_month_revenue,
               -- Is this the #1 product in its category this month?
               CASE 
                   WHEN RANK() OVER (PARTITION BY month, category ORDER BY revenue DESC) = 1 
                   THEN 'Category Leader' 
                   ELSE 'Follower' 
               END as category_position
        FROM ecommerce_sales
    ),
    trend_analysis AS (
        SELECT *,
               -- Month-over-month growth
               COALESCE(revenue - prev_month_revenue, 0) as mom_growth,
               CASE 
                   WHEN prev_month_revenue IS NULL THEN 'New Product'
                   WHEN revenue > prev_month_revenue THEN 'Growing'
                   WHEN revenue < prev_month_revenue THEN 'Declining'
                   ELSE 'Stable'
               END as trend_direction,
               -- Growth percentage (handle division by zero)
               CASE 
                   WHEN prev_month_revenue IS NULL OR prev_month_revenue = 0 THEN NULL
                   ELSE ROUND((revenue - prev_month_revenue) * 100.0 / prev_month_revenue, 1)
               END as mom_growth_pct
        FROM base_analysis
    )
    SELECT month,
           category,
           product,
           sales_rep,
           revenue,
           category_rank,
           rep_overall_rank,
           category_position,
           mom_growth,
           mom_growth_pct,
           trend_direction
    FROM trend_analysis
    ORDER BY month, category, category_rank;
    

    This solution demonstrates:

    • Multiple partitioning strategies (by month+category, by month, by product)
    • Proper handling of NULL values with COALESCE and CASE statements
    • Combining window functions with conditional logic
    • Creating business-friendly labels and insights

    Common Mistakes & Troubleshooting

    Mistake 1: Using Window Functions in WHERE Clauses

    -- This will NOT work
    SELECT product, revenue,
           RANK() OVER (ORDER BY revenue DESC) as rank
    FROM products
    WHERE RANK() OVER (ORDER BY revenue DESC) <= 3;  -- ERROR!
    

    Why it fails: Window functions are evaluated after WHERE clauses. SQL processes clauses in this order: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT.

    The fix: Use a CTE or subquery:

    WITH ranked_products AS (
        SELECT product, revenue,
               RANK() OVER (ORDER BY revenue DESC) as rank
        FROM products
    )
    SELECT * FROM ranked_products WHERE rank <= 3;
    

    Mistake 2: Forgetting to Handle NULLs in LAG Calculations

    -- Problematic: division by zero when LAG returns NULL
    SELECT month, revenue,
           (revenue - LAG(revenue) OVER (ORDER BY month)) / LAG(revenue) OVER (ORDER BY month) * 100 as growth_pct
    FROM monthly_data;
    

    The fix: Always handle NULL cases:

    SELECT month, revenue,
           CASE 
               WHEN LAG(revenue) OVER (ORDER BY month) IS NULL THEN NULL
               WHEN LAG(revenue) OVER (ORDER BY month) = 0 THEN NULL
               ELSE (revenue - LAG(revenue) OVER (ORDER BY month)) * 100.0 / LAG(revenue) OVER (ORDER BY month)
           END as growth_pct
    FROM monthly_data;
    

    Mistake 3: Incorrect Partitioning Leading to Wrong Results

    -- Wrong: This ranks across ALL months, not within each month
    SELECT month, product, revenue,
           RANK() OVER (ORDER BY revenue DESC) as monthly_rank  -- Missing PARTITION BY
    FROM monthly_sales;
    
    -- Correct: Partition by month to rank within each month
    SELECT month, product, revenue,
           RANK() OVER (PARTITION BY month ORDER BY revenue DESC) as monthly_rank
    FROM monthly_sales;
    

    Mistake 4: ROW_NUMBER with Non-Deterministic Ordering

    -- Problematic: ties broken randomly, results may vary between runs
    SELECT product, revenue,
           ROW_NUMBER() OVER (ORDER BY revenue DESC) as row_num
    FROM products;
    

    The fix: Include additional columns to make ordering deterministic:

    SELECT product, revenue,
           ROW_NUMBER() OVER (ORDER BY revenue DESC, product) as row_num
    FROM products;
    

    Mistake 5: Inefficient Window Function Usage

    -- Inefficient: same window specification repeated multiple times
    SELECT month, product, revenue,
           RANK() OVER (PARTITION BY month ORDER BY revenue DESC),
           DENSE_RANK() OVER (PARTITION BY month ORDER BY revenue DESC),
           PERCENT_RANK() OVER (PARTITION BY month ORDER BY revenue DESC)
    FROM sales;
    
    -- Efficient: define window once
    SELECT month, product, revenue,
           RANK() OVER w,
           DENSE_RANK() OVER w,
           PERCENT_RANK() OVER w
    FROM sales
    WINDOW w AS (PARTITION BY month ORDER BY revenue DESC);
    

    Summary & Next Steps

    Window functions transform how you approach analytical SQL. Instead of complex self-joins and subqueries, you now have clean, readable tools for ranking (RANK, ROW_NUMBER, DENSE_RANK), time-series analysis (LAG, LEAD), and comparative analysis through intelligent partitioning.

    The key insights you've mastered:

    RANK vs ROW_NUMBER: RANK handles ties by giving them the same rank and skipping subsequent ranks, while ROW_NUMBER assigns unique sequential numbers regardless of ties. DENSE_RANK gives tied values the same rank but doesn't skip subsequent ranks.

    LAG and LEAD: These functions unlock period-over-period analysis, trend detection, and forward-looking insights. Always handle NULL values properly, especially in calculations, and remember you can look back or forward multiple periods.

    Partitioning Strategy: The PARTITION BY clause is where the real power lies. You can segment your analysis by business dimensions (region, category, time periods) to create sophisticated multi-level analytics in a single query.

    Performance Considerations: Window functions can be expensive on large datasets. Create appropriate indexes on partition and order columns, filter datasets before applying window functions, and reuse window specifications when possible.

    Logical Next Steps

    1. Advanced Window Functions: Explore NTILE for quartile analysis, FIRST_VALUE/LAST_VALUE for range analytics, and aggregate window functions like SUM() OVER for running totals and moving averages.

    2. Window Frames: Learn about ROWS and RANGE frame specifications to control exactly which rows your window function considers—essential for moving averages, cumulative sums, and sliding window analysis.

    3. Complex Analytical Patterns: Combine window functions with CTEs and conditional logic to build sophisticated analytical frameworks for cohort analysis, funnel tracking, and advanced business intelligence scenarios.

    Work With Us

    From insight to implementation

    Reading is the start. When you're ready to build the data, automation, or AI systems behind it, our team turns strategy into shipped results.

    Let's Build

    Advanced SQL Queries

    Next

    Common Table Expressions (CTEs) for Cleaner SQL

    Related Insights

    SQLExpert

    Writing SQL to Detect and Resolve Lock Contention: Using Query Plans, Wait Statistics, and Transaction Isolation to Diagnose and Fix Blocking in High-Concurrency Systems

    31 min
    SQLFoundation

    Filtering Rows Before and After Aggregation: WHERE vs HAVING in GROUP BY Queries

    15 min
    SQLPractitioner

    Writing SQL to Simulate LAG and LEAD Across Partition Boundaries: Handling Edge Cases Where Window Functions Return NULL

    22 min

    On this page

    • Prerequisites
    • Understanding Window Functions vs Traditional Approaches
    • RANK vs ROW_NUMBER: Handling Ties Like a Pro
    • When to Use Each Function
    • LAG and LEAD: Time Series Analysis Made Simple
    • Basic LAG Usage for Period-over-Period Analysis
    • Advanced LAG Patterns: Multiple Periods and Default Values
    • LEAD for Forward-Looking Analysis
    • Combining LAG with Conditional Logic for Trend Analysis
    • Advanced Partitioning Strategies
    • Multi-Level Partitioning
    • Dynamic Partitioning for Comparative Analysis
    • Conditional Partitioning
    • Performance Optimization for Window Functions
    • Index Strategy
    • Limiting Window Function Scope
    • Reusing Window Specifications
    • Hands-On Exercise: E-commerce Performance Dashboard
    • Setup
    • Your Task
    • Requirements
    • Starter Code Structure
    • Solution
    • Common Mistakes & Troubleshooting
    • Mistake 1: Using Window Functions in WHERE Clauses
    • Mistake 2: Forgetting to Handle NULLs in LAG Calculations
    • Mistake 3: Incorrect Partitioning Leading to Wrong Results
    • Mistake 4: ROW_NUMBER with Non-Deterministic Ordering
    • Mistake 5: Inefficient Window Function Usage
    • Summary & Next Steps
    • Logical Next Steps