DevDocsDev Docs
SQL Fundamentals

Window Functions

Perform calculations across related rows without collapsing them with OVER, PARTITION BY, and window frames

Window functions perform calculations across a set of rows related to the current row—without grouping them into a single output row. They're essential for rankings, running totals, moving averages, and comparisons.

How Window Functions Work

Window Function Syntax

function_name(expression) OVER (
    [PARTITION BY partition_expression]
    [ORDER BY sort_expression [ASC|DESC]]
    [frame_clause]
)
-- OVER() without options: entire result set is the window
SELECT 
    name,
    price,
    AVG(price) OVER() AS avg_all_products,
    price - AVG(price) OVER() AS diff_from_avg
FROM products;
namepriceavg_all_productsdiff_from_avg
Widget50100-50
Gadget15010050
Thing1001000
-- PARTITION BY: Create separate windows for each group
SELECT 
    category_id,
    name,
    price,
    AVG(price) OVER(PARTITION BY category_id) AS category_avg,
    price - AVG(price) OVER(PARTITION BY category_id) AS diff_from_category_avg
FROM products;
-- ORDER BY: Define order within the window for running calculations
SELECT 
    created_at,
    total,
    SUM(total) OVER(ORDER BY created_at) AS running_total,
    ROW_NUMBER() OVER(ORDER BY created_at) AS row_num
FROM orders;
created_attotalrunning_totalrow_num
2024-01-011001001
2024-01-021502502
2024-01-032004503
-- Frame: Define exactly which rows to include
SELECT 
    date,
    revenue,
    -- 3-day moving average (current row + 2 preceding)
    AVG(revenue) OVER(
        ORDER BY date 
        ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
    ) AS moving_avg_3d,
    -- Sum of current row and next row
    SUM(revenue) OVER(
        ORDER BY date
        ROWS BETWEEN CURRENT ROW AND 1 FOLLOWING
    ) AS sum_with_next
FROM daily_sales;

Window Frame Types

-- ROWS: Physical rows
SELECT 
    date,
    revenue,
    -- Previous 2 physical rows + current
    SUM(revenue) OVER(
        ORDER BY date
        ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
    ) AS sum_3_rows
FROM daily_sales;

Frame boundaries for ROWS:

  • UNBOUNDED PRECEDING: Start of partition
  • n PRECEDING: n rows before current
  • CURRENT ROW: The current row
  • n FOLLOWING: n rows after current
  • UNBOUNDED FOLLOWING: End of partition
-- RANGE: Logical values (ties included)
SELECT 
    sale_date,
    amount,
    -- All rows with same date + preceding dates
    SUM(amount) OVER(
        ORDER BY sale_date
        RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS running_total
FROM sales;

ROWS vs RANGE

With ROWS, each physical row is separate. With RANGE, rows with the same ORDER BY value are treated together.

-- GROUPS: Groups of tied rows (PostgreSQL 11+)
SELECT 
    category,
    product,
    price,
    -- Average including 1 group before and 1 after
    AVG(price) OVER(
        ORDER BY category
        GROUPS BETWEEN 1 PRECEDING AND 1 FOLLOWING
    ) AS avg_nearby_categories
FROM products;
Frame TypeUnitTies
ROWSPhysical rowsSeparate
RANGEValue rangeIncluded together
GROUPSGroups of tiesGrouped
-- Example with duplicate dates
-- Data: 2024-01-01 (x2), 2024-01-02 (x1)

-- ROWS BETWEEN 1 PRECEDING AND CURRENT ROW
-- Row 1: Only itself
-- Row 2 (same date): Rows 1-2
-- Row 3: Rows 2-3

-- RANGE BETWEEN 1 PRECEDING AND CURRENT ROW  
-- Row 1: Rows 1-2 (same date = same range)
-- Row 2: Rows 1-2
-- Row 3: Rows 1-3 (value-based range)

Ranking Functions

-- Unique number for each row
SELECT 
    name,
    score,
    ROW_NUMBER() OVER(ORDER BY score DESC) AS row_num
FROM students;
namescorerow_num
Alice951
Bob902
Charlie903
Diana854

Non-Deterministic

With ties, ROW_NUMBER is non-deterministic unless you add a tiebreaker.

-- Rank with gaps after ties
SELECT 
    name,
    score,
    RANK() OVER(ORDER BY score DESC) AS rank
FROM students;
namescorerank
Alice951
Bob902
Charlie902
Diana854

Note: Rank 3 is skipped because 2 people tied for 2nd.

-- Rank without gaps
SELECT 
    name,
    score,
    DENSE_RANK() OVER(ORDER BY score DESC) AS dense_rank
FROM students;
namescoredense_rank
Alice951
Bob902
Charlie902
Diana853

Note: No gaps—Diana is 3rd, not 4th.

-- Divide into n buckets
SELECT 
    name,
    score,
    NTILE(4) OVER(ORDER BY score DESC) AS quartile
FROM students;
namescorequartile
Alice951
Bob902
Charlie853
Diana804

Use cases: Percentiles, quartiles, deciles

FunctionTiesGapsUse Case
ROW_NUMBERArbitraryNoUnique IDs, pagination
RANKSame rankYesCompetition rankings
DENSE_RANKSame rankNoSequential rankings
NTILE(n)N/AN/ABuckets/percentiles
-- All together
SELECT 
    name,
    score,
    ROW_NUMBER() OVER(ORDER BY score DESC) AS row_num,
    RANK() OVER(ORDER BY score DESC) AS rank,
    DENSE_RANK() OVER(ORDER BY score DESC) AS dense_rank,
    NTILE(4) OVER(ORDER BY score DESC) AS quartile
FROM students;
-- Access previous row's value
SELECT 
    date,
    revenue,
    LAG(revenue) OVER(ORDER BY date) AS prev_day_revenue,
    LAG(revenue, 7) OVER(ORDER BY date) AS week_ago_revenue,
    LAG(revenue, 1, 0) OVER(ORDER BY date) AS prev_or_zero  -- Default if no prev
FROM daily_sales;
daterevenueprev_dayweek_ago
Day 1100NULLNULL
Day 2150100NULL
Day 3120150NULL
-- Access next row's value
SELECT 
    date,
    revenue,
    LEAD(revenue) OVER(ORDER BY date) AS next_day_revenue,
    LEAD(revenue, 7) OVER(ORDER BY date) AS next_week_revenue
FROM daily_sales;

-- Calculate day-over-day change
SELECT 
    date,
    revenue,
    revenue - LAG(revenue) OVER(ORDER BY date) AS dod_change,
    ROUND(
        (revenue - LAG(revenue) OVER(ORDER BY date)) * 100.0 
        / LAG(revenue) OVER(ORDER BY date), 
        2
    ) AS dod_pct_change
FROM daily_sales;
-- First value in the window
SELECT 
    category_id,
    name,
    price,
    FIRST_VALUE(name) OVER(
        PARTITION BY category_id 
        ORDER BY price DESC
    ) AS most_expensive_in_category,
    FIRST_VALUE(price) OVER(
        PARTITION BY category_id 
        ORDER BY price DESC
    ) AS max_price_in_category
FROM products;
-- Last value in the window
-- ⚠️ Requires proper frame definition!
SELECT 
    category_id,
    name,
    price,
    LAST_VALUE(name) OVER(
        PARTITION BY category_id 
        ORDER BY price DESC
        ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
    ) AS cheapest_in_category
FROM products;

LAST_VALUE Gotcha

By default, the window frame ends at CURRENT ROW, not the partition end. Always specify the full frame for LAST_VALUE!

-- Get the Nth value in the window
SELECT 
    category_id,
    name,
    price,
    NTH_VALUE(name, 2) OVER(
        PARTITION BY category_id 
        ORDER BY price DESC
        ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
    ) AS second_most_expensive
FROM products;

Aggregate Window Functions

-- Any aggregate function can be used as a window function
SELECT 
    date,
    category_id,
    revenue,
    -- Aggregates over entire partition
    SUM(revenue) OVER(PARTITION BY category_id) AS category_total,
    AVG(revenue) OVER(PARTITION BY category_id) AS category_avg,
    COUNT(*) OVER(PARTITION BY category_id) AS category_count,
    -- Running aggregates
    SUM(revenue) OVER(
        PARTITION BY category_id 
        ORDER BY date
    ) AS running_total,
    AVG(revenue) OVER(
        PARTITION BY category_id 
        ORDER BY date
        ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
    ) AS moving_avg_7d
FROM sales;

Common Patterns

Named Window Definitions

-- Define once, use multiple times
SELECT 
    date,
    category_id,
    revenue,
    SUM(revenue) OVER category_window AS category_total,
    AVG(revenue) OVER category_window AS category_avg,
    RANK() OVER category_window AS category_rank
FROM sales
WINDOW category_window AS (
    PARTITION BY category_id 
    ORDER BY revenue DESC
);

-- Multiple named windows
SELECT 
    *,
    SUM(amount) OVER running AS running_total,
    AVG(amount) OVER moving AS moving_avg
FROM transactions
WINDOW 
    running AS (ORDER BY date),
    moving AS (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW);

Performance Considerations

Add Indexes for ORDER BY Columns

Window functions benefit from indexes on the columns used in ORDER BY.

CREATE INDEX idx_orders_date ON orders(created_at);
CREATE INDEX idx_orders_customer_date ON orders(customer_id, created_at);

Minimize Window Function Calls

Combine multiple window calculations using the same window.

-- ❌ Multiple window scans
SELECT 
    SUM(x) OVER(ORDER BY date),
    AVG(x) OVER(ORDER BY date),
    COUNT(x) OVER(ORDER BY date)
FROM t;

-- ✅ Named window (potentially one scan)
SELECT 
    SUM(x) OVER w,
    AVG(x) OVER w,
    COUNT(x) OVER w
FROM t
WINDOW w AS (ORDER BY date);

Be Careful with ROWS vs RANGE

RANGE can be slower because it must handle ties.

Filter After Window Calculation

Window functions execute after WHERE but before final SELECT. Use CTEs or subqueries to filter on window results.

-- ✅ Filter window results in outer query
WITH ranked AS (
    SELECT *, RANK() OVER(ORDER BY score DESC) AS r
    FROM scores
)
SELECT * FROM ranked WHERE r <= 10;

Next Steps

On this page