Window functions and CTEs are where SQL stops being simple and becomes powerful. These patterns let you do complex analytics that would be impossible (or slow) with GROUP BY alone.


Window Functions: Overview

A window function operates on a “window” of rows around the current row, without collapsing them.

Compare: GROUP BY vs Window Functions

-- GROUP BY: collapses rows (reduces row count)
SELECT category, COUNT(*) as product_count
FROM products
GROUP BY category;
-- Result: 1 row per category
 
-- Window Function: keeps all rows (each gets a window calculation)
SELECT 
    product_id, 
    product_name, 
    category,
    COUNT(*) OVER (PARTITION BY category) as category_product_count
FROM products;
-- Result: Every product row, plus category count

Ranking Window Functions

ROW_NUMBER: Sequential Ranking

SELECT 
    user_id,
    name,
    purchase_total,
    ROW_NUMBER() OVER (ORDER BY purchase_total DESC) as rank
FROM users;

Result: Every user gets a unique sequential number (1, 2, 3…)

user_idnamepurchase_totalrank
5Alice50001
3Bob45002
1Carol45003
2Diana30004

Key: Even if Bob and Carol have same total, they get different ranks (2 vs 3)

RANK: Ranks with Ties

SELECT 
    user_id,
    name,
    purchase_total,
    RANK() OVER (ORDER BY purchase_total DESC) as rank
FROM users;

Result: Ties get the SAME rank

user_idnamepurchase_totalrank
5Alice50001
3Bob45002
1Carol45002
2Diana30004

Note: There’s no rank 3 (skipped because of the tie)

DENSE_RANK: Ranks without Gaps

SELECT 
    user_id,
    name,
    purchase_total,
    DENSE_RANK() OVER (ORDER BY purchase_total DESC) as dense_rank
FROM users;

Result: Ties get same rank, no gaps

user_idnamepurchase_totaldense_rank
5Alice50001
3Bob45002
1Carol45002
2Diana30003

Note: Rank 3 is used (no gap)

When to Use Which

FunctionUse WhenExample
ROW_NUMBERYou want unique ranks (pagination)“Give me rows 10-20”
RANKYou want to show ties, respect gaps”Top 3 performers” (2 tied for 2nd)
DENSE_RANKYou want to show ties, no gaps”Top 3 categories”

Ranking Within Groups (PARTITION BY)

Top N Per Category

SELECT 
    product_id,
    product_name,
    category,
    price,
    ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) as rank_in_category
FROM products;

Result: Each product ranked within its category

product_idproduct_namecategorypricerank_in_category
101MacBook Proelectronics19991
102iPhoneelectronics9992
201Hemingwaybooks251
202Orwellbooks152

Get Top N Per Group

SELECT * FROM (
    SELECT 
        product_id,
        product_name,
        category,
        price,
        ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) as rank_in_category
    FROM products
) ranked
WHERE rank_in_category <= 3;

Result: Top 3 products per category


Aggregate Window Functions

Running Total

SELECT 
    order_date,
    order_id,
    total,
    SUM(total) OVER (ORDER BY order_date) as running_total
FROM orders;

Result: Cumulative sum over time

order_dateorder_idtotalrunning_total
2026-01-011100100
2026-01-022150250
2026-01-023200450
2026-01-03450500

Average Over Last N Rows

SELECT 
    order_date,
    order_id,
    total,
    AVG(total) OVER (
        ORDER BY order_date 
        ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
    ) as avg_last_3
FROM orders;

Result: 3-day moving average

order_dateorder_idtotalavg_last_3
2026-01-011100100.00
2026-01-022150125.00
2026-01-023200150.00
2026-01-03450150.00

LAG & LEAD: Access Previous/Next Rows

Compare to Previous Row

SELECT 
    order_date,
    total,
    LAG(total) OVER (ORDER BY order_date) as previous_total,
    total - LAG(total) OVER (ORDER BY order_date) as change
FROM orders;

Result: Day-over-day change

order_datetotalprevious_totalchange
2026-01-011000NULLNULL
2026-01-0211501000150
2026-01-039501150-200

Find Gaps in Sequence

SELECT 
    transaction_id,
    LAG(transaction_id) OVER (ORDER BY transaction_id) as prev_id,
    transaction_id - LAG(transaction_id) OVER (ORDER BY transaction_id) - 1 as gap
FROM transactions;

Result: Find missing transaction IDs


CTEs (Common Table Expressions)

CTEs are temporary named queries you can reference. Think of them as “temporary tables in memory”.

Basic CTE

WITH user_totals AS (
    SELECT 
        user_id,
        COUNT(*) as order_count,
        SUM(total) as lifetime_value
    FROM orders
    GROUP BY user_id
)
SELECT * FROM user_totals
WHERE lifetime_value > 1000;

Equivalent to: A subquery, but more readable

Multiple CTEs

WITH daily_revenue AS (
    SELECT 
        DATE(order_date) as date,
        SUM(total) as revenue
    FROM orders
    GROUP BY DATE(order_date)
),
daily_orders AS (
    SELECT 
        DATE(order_date) as date,
        COUNT(*) as order_count
    FROM orders
    GROUP BY DATE(order_date)
)
SELECT 
    d.date,
    d.revenue,
    o.order_count,
    d.revenue / o.order_count as avg_order_size
FROM daily_revenue d
JOIN daily_orders o ON d.date = o.date;

Recursive CTE (Generate Series)

WITH RECURSIVE dates AS (
    SELECT DATE '2026-01-01' as date
    UNION ALL
    SELECT date + INTERVAL 1 DAY
    FROM dates
    WHERE date < DATE '2026-12-31'
)
SELECT date FROM dates;

Result: Every date in 2026


Complex Real-World Patterns

Find Churn (Customers Who Stopped Buying)

WITH customer_months AS (
    SELECT 
        user_id,
        DATE_TRUNC('month', order_date) as month
    FROM orders
    GROUP BY user_id, DATE_TRUNC('month', order_date)
),
month_numbers AS (
    SELECT 
        user_id,
        month,
        ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY month) as month_num,
        LAG(month) OVER (PARTITION BY user_id ORDER BY month) as prev_month
    FROM customer_months
)
SELECT 
    user_id,
    prev_month,
    month,
    (month - prev_month) > INTERVAL 1 MONTH as churned
FROM month_numbers
WHERE churned = TRUE;

Result: Users who skipped at least one month

Cohort Analysis

WITH user_cohorts AS (
    SELECT 
        user_id,
        DATE_TRUNC('month', MIN(order_date)) as cohort_month,
        DATE_TRUNC('month', order_date) as order_month
    FROM orders
    GROUP BY user_id, DATE_TRUNC('month', order_date)
),
cohort_ages AS (
    SELECT 
        cohort_month,
        order_month,
        (EXTRACT(YEAR FROM order_month) - EXTRACT(YEAR FROM cohort_month)) * 12 +
        (EXTRACT(MONTH FROM order_month) - EXTRACT(MONTH FROM cohort_month)) as months_since_cohort
    FROM user_cohorts
)
SELECT 
    cohort_month,
    months_since_cohort,
    COUNT(DISTINCT user_id) as user_count
FROM cohort_ages
GROUP BY cohort_month, months_since_cohort
ORDER BY cohort_month, months_since_cohort;

Window vs GROUP BY Decision

NeedUseExample
Collapse rows into groupsGROUP BY”Revenue per category”
Keep all rows + calculationWindow”Rank each product”
Running totalsWindow + ORDER BY”Cumulative daily revenue”
Top N per groupWindow + PARTITION BY”Top 3 products per category”

Performance Tips

PatternPerformanceWhy
Window on indexed column🟢 FastIndex helps
PARTITION BY with large groups⚠️ MediumMore work per group
Multiple window functions⚠️ MediumSingle pass, parallel
Recursive CTE🔴 SlowIterative (avoid for big data)

Tips & Gotchas

  • ORDER BY matters in window functions. Always specify it.
-- Without ORDER BY, window is entire table
ROW_NUMBER() OVER (PARTITION BY category)  -- ❌ Unpredictable
 
-- With ORDER BY, clear ranking
ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC)  -- ✅
  • CTEs don’t persist after query. They’re temporary.
-- ✅ Works: reference CTE in main query
WITH temp AS (SELECT ...)
SELECT * FROM temp;
 
-- ❌ Error: CTE doesn't exist in next query
SELECT * FROM temp;
  • Window functions run AFTER WHERE. You can’t filter with them in WHERE clause.
-- ❌ Error
SELECT * FROM products WHERE ROW_NUMBER() OVER (...) = 1;
 
-- ✅ Use subquery
SELECT * FROM (
    SELECT *, ROW_NUMBER() OVER (...) as rn
    FROM products
) ranked
WHERE rn = 1;


Key Takeaway:
Window functions keep all rows while calculating aggregates. CTEs make complex queries readable. Together, they enable advanced analytics that GROUP BY alone can’t achieve. Use PARTITION BY to rank within groups, ORDER BY to create running totals, and CTEs to organize complexity.