Tuesday, September 1, 2026
HomeData ScienceSQL Window Functions – Complete Guide for Data Scientists 2026

SQL Window Functions – Complete Guide for Data Scientists 2026

Table of Content

SQL window functions are the most powerful and underused feature in a data scientist’s SQL toolkit. They let you perform calculations across a set of rows related to the current row — without collapsing rows like GROUP BY does. Ranking, running totals, moving averages, cohort analysis, and session detection all become elegant one-query solutions with window functions.

The Anatomy of a Window Function

function_name() OVER (
    PARTITION BY column1, column2   -- define groups (optional)
    ORDER BY column3                -- order within each group
    ROWS BETWEEN 2 PRECEDING        -- frame clause (optional)
         AND CURRENT ROW
)

PARTITION BY divides rows into groups — like GROUP BY but without collapsing rows. ORDER BY defines the sequence within each partition. The ROWS/RANGE frame clause defines which rows to include in the calculation relative to the current row.

Ranking Functions

a close up of a sheet of paper with numbers on it
Photo by Bozhin Karaivanov on Unsplash
-- Employee salaries ranked within each department
SELECT
    employee_id,
    name,
    department,
    salary,
    ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS row_num,
    RANK()       OVER (PARTITION BY department ORDER BY salary DESC) AS rank,
    DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS dense_rank,
    NTILE(4)     OVER (PARTITION BY department ORDER BY salary DESC) AS quartile
FROM employees;

-- ROW_NUMBER: unique sequential (1,2,3,4 — no ties)
-- RANK:       gaps on ties (1,2,2,4 — salary tie → both ranked 2, next is 4)
-- DENSE_RANK: no gaps (1,2,2,3 — no skipped ranks)
-- NTILE(4):   divides into 4 equal buckets (quartiles)

-- Get only the top earner per department
WITH ranked AS (
    SELECT *,
           ROW_NUMBER() OVER (PARTITION BY department
                              ORDER BY salary DESC) AS rn
    FROM employees
)
SELECT * FROM ranked WHERE rn = 1;

Aggregate Window Functions

SELECT
    sale_date,
    region,
    revenue,
    -- Running total (cumulative sum)
    SUM(revenue)   OVER (PARTITION BY region
                         ORDER BY sale_date
                         ROWS BETWEEN UNBOUNDED PRECEDING
                              AND CURRENT ROW)      AS running_total,
    -- 7-day moving average
    AVG(revenue)   OVER (PARTITION BY region
                         ORDER BY sale_date
                         ROWS BETWEEN 6 PRECEDING
                              AND CURRENT ROW)      AS moving_avg_7d,
    -- Share of total (percentage contribution)
    revenue /
    SUM(revenue)   OVER (PARTITION BY region) * 100  AS pct_of_region,
    -- Running max
    MAX(revenue)   OVER (PARTITION BY region
                         ORDER BY sale_date)         AS running_max,
    -- Count of rows so far (cumulative count)
    COUNT(*)       OVER (PARTITION BY region
                         ORDER BY sale_date
                         ROWS UNBOUNDED PRECEDING)   AS cumulative_days
FROM daily_sales;

LAG and LEAD – Comparing Rows

a group of people rowing a boat
Photo by David Trinks on Unsplash
SELECT
    sale_date,
    revenue,
    -- Previous day's revenue
    LAG(revenue, 1, 0)  OVER (ORDER BY sale_date)  AS prev_day_revenue,
    -- Next day's revenue
    LEAD(revenue, 1, 0) OVER (ORDER BY sale_date)  AS next_day_revenue,
    -- Day-over-day change
    revenue - LAG(revenue, 1, 0) OVER (ORDER BY sale_date) AS daily_change,
    -- % change vs previous day
    ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY sale_date))
          / NULLIF(LAG(revenue) OVER (ORDER BY sale_date), 0), 2) AS pct_change,
    -- Compare to same day last week (7 rows back)
    revenue - LAG(revenue, 7) OVER (ORDER BY sale_date)  AS wow_change
FROM daily_sales;

Percentiles and Distribution

SELECT
    product_id,
    revenue,
    -- Exact percentile rank (0 to 1)
    PERCENT_RANK()  OVER (ORDER BY revenue) AS pct_rank,
    -- Cumulative distribution (fraction of values <= current)
    CUME_DIST()     OVER (ORDER BY revenue) AS cume_dist,
    -- Assign to percentile bucket
    NTILE(100)      OVER (ORDER BY revenue) AS percentile,
    -- Is this product in the top 10%?
    CASE WHEN PERCENT_RANK() OVER (ORDER BY revenue) >= 0.9
         THEN 'Top 10%' ELSE 'Rest' END     AS tier
FROM product_sales;

Session Detection

-- Detect user sessions (gap > 30 minutes = new session)
WITH events_with_prev AS (
    SELECT
        user_id,
        event_time,
        LAG(event_time) OVER (PARTITION BY user_id
                              ORDER BY event_time) AS prev_event_time
    FROM user_events
),
session_starts AS (
    SELECT *,
           CASE WHEN prev_event_time IS NULL
                     OR event_time - prev_event_time > INTERVAL '30 minutes'
                THEN 1 ELSE 0 END AS is_new_session
    FROM events_with_prev
),
sessions AS (
    SELECT *,
           SUM(is_new_session) OVER (PARTITION BY user_id
                                     ORDER BY event_time) AS session_id
    FROM session_starts
)
SELECT
    user_id,
    session_id,
    MIN(event_time) AS session_start,
    MAX(event_time) AS session_end,
    COUNT(*)        AS events_in_session,
    MAX(event_time) - MIN(event_time) AS session_duration
FROM sessions
GROUP BY user_id, session_id
ORDER BY user_id, session_start;

Cohort Retention Analysis

WITH cohorts AS (
    -- Assign each user to their signup month cohort
    SELECT
        user_id,
        DATE_TRUNC('month', MIN(order_date)) AS cohort_month
    FROM orders
    GROUP BY user_id
),
user_activity AS (
    SELECT
        o.user_id,
        c.cohort_month,
        DATE_TRUNC('month', o.order_date) AS activity_month,
        -- Months since signup
        DATEDIFF('month', c.cohort_month,
                 DATE_TRUNC('month', o.order_date)) AS months_since_signup
    FROM orders o
    JOIN cohorts c ON o.user_id = c.user_id
)
SELECT
    cohort_month,
    months_since_signup,
    COUNT(DISTINCT user_id) AS active_users,
    FIRST_VALUE(COUNT(DISTINCT user_id))
        OVER (PARTITION BY cohort_month
              ORDER BY months_since_signup) AS cohort_size,
    ROUND(100.0 * COUNT(DISTINCT user_id) /
          FIRST_VALUE(COUNT(DISTINCT user_id))
              OVER (PARTITION BY cohort_month
                    ORDER BY months_since_signup), 2) AS retention_rate
FROM user_activity
GROUP BY cohort_month, months_since_signup
ORDER BY cohort_month, months_since_signup;

Conclusion

Window functions unlock a class of analytical queries that would otherwise require self-joins, subqueries, or post-processing in Python — making them slower and harder to maintain. Every data scientist who writes SQL should have ROW_NUMBER, LAG/LEAD, running SUM, and moving AVG as automatic tools. Once mastered, window functions reduce complex multi-step analyses to single, readable, high-performance SQL queries that run directly where the data lives.

Leave feedback about this

  • Rating

Durgesh Kekare
Durgesh Kekarehttps://www.dataexpertise.in
Durgesh Kekare is a data science educator and founder of DataExpertise.in. With expertise in Python, machine learning, and analytics, he helps 10,000+ learners break into data careers.

Latest Posts

List of Categories