Window functions are powerful but can destroy query performance at scale. Learn how to design partition strategies, choose the right frame boundaries, and align indexes to eliminate sort spills and cut execution times by orders of magnitude.

You've written a window function query that works perfectly on a 10,000-row sample dataset. You promote it to production, point it at 50 million rows, and watch it grind your database to a halt. The query plan shows a sort operation chewing through gigabytes of data, your DBA is pinging you on Slack, and the dashboard that was supposed to refresh every 30 seconds has been spinning for eight minutes. Sound familiar?
Window functions are among the most powerful tools in SQL — they let you compute running totals, moving averages, rankings, and inter-row comparisons without collapsing your result set. But they carry a dirty secret: they can be extraordinarily expensive at scale. The reason isn't the functions themselves. It's that most practitioners learn how to write window functions before they learn how the database actually executes them. The gap between "it returns the right answer" and "it returns the right answer in under a second" is almost entirely about understanding the execution model.
By the end of this lesson, you'll be able to design window function queries that perform well on production-scale data. You'll understand how the optimizer handles PARTITION BY, ORDER BY, and frame specifications, how to structure indexes that let the engine skip sorting entirely, and how to diagnose and fix the most common performance killers. You'll also learn when window functions should be replaced with a different approach altogether.
What you'll learn:
PARTITION BY clauses that minimize data movement and sort overheadROWS vs RANGE) affect both correctness and performanceYou should be comfortable writing window functions with RANK(), ROW_NUMBER(), LAG(), and aggregate variants like SUM() OVER(). If you need a refresher, the lesson on Window Functions: RANK, ROW_NUMBER, and LAG covers the syntax and semantics thoroughly. You should also understand basic query execution concepts — if terms like "sort operator," "hash aggregate," or "index seek" are unfamiliar, skim SQL Query Optimization: Reading Execution Plans - Advanced Performance Analysis before continuing.
You'll need access to a database that supports window functions — PostgreSQL, SQL Server, or BigQuery all work. The examples use PostgreSQL syntax but translate directly with minor adjustments.
Before you can optimize something, you need a mental model of what the engine is doing. Let's trace the execution of a realistic query step by step.
Suppose you're working with an e-commerce order dataset and you want to compute each customer's running revenue total, ordered by order date:
SELECT
customer_id,
order_date,
revenue,
SUM(revenue) OVER (
PARTITION BY customer_id
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM orders;
Here's what the engine actually does to produce that result:
Step 1: Scan the base data. The engine reads rows from the orders table — either a full table scan or an index scan, depending on available indexes and any WHERE clause filters.
Step 2: Sort. This is usually the expensive part. The engine must sort the data by (customer_id, order_date) so that rows within each partition appear together and in the right order. On 50 million rows, a sort can mean multiple gigabytes of data being written to disk if it doesn't fit in work_mem (PostgreSQL) or sort buffer (SQL Server).
Step 3: Compute the window. With the data sorted, the engine makes a single pass through each partition, applying the window function frame-by-frame. This step is generally linear — it's fast relative to the sort.
Step 4: Return rows. Unlike GROUP BY, the result set has the same number of rows as the input. No collapsing happens.
The takeaway: the sort is your enemy. Everything else — the actual window computation — is relatively cheap. Your optimization goal is almost always to either eliminate the sort, reduce the amount of data being sorted, or reduce the sort's memory footprint so it stays in RAM.
Key insight
Window functions don't inherently require a sort if the data is already in the right order — which is exactly what indexes provide. When your index layout matches your PARTITION BY + ORDER BY specification, the optimizer can use an index scan instead of an explicit sort, dropping execution time dramatically.
The PARTITION BY clause divides your dataset into independent windows. It's analogous to GROUP BY in the sense that the engine needs to bring rows with the same partition key together — but unlike GROUP BY, it must retain all rows.
High-cardinality partition keys (like customer_id when you have 2 million customers) mean many small partitions. Low-cardinality keys (like region when you have 5 regions) mean few large partitions. Both extremes have performance implications.
High-cardinality, small partitions: The sort overhead is actually proportional to N log N within each partition, but the startup cost of initializing window state is repeated for each partition. With millions of tiny partitions, this overhead adds up. More importantly, it's hard to build an index that's selective enough to avoid scanning large chunks of the table.
Low-cardinality, large partitions: Each partition requires sorting a large chunk of data. If a single partition doesn't fit in memory, you get a disk spill, which is catastrophic for performance.
The sweet spot is partition keys that create reasonably sized, bounded partitions. In an orders dataset, customer_id (with an average of 20 orders per customer) is far better than product_category (with millions of orders per category).
A mistake practitioners often make is adding extra columns to PARTITION BY in hopes of making it "more specific" when their logic doesn't require it. Consider:
-- Unnecessarily specific partitioning
SUM(revenue) OVER (
PARTITION BY customer_id, order_month, region
ORDER BY order_date
)
-- If you only need per-customer running totals:
SUM(revenue) OVER (
PARTITION BY customer_id
ORDER BY order_date
)
Extra partition columns mean a more complex sort key and a more specific index requirement. If your logic truly requires a three-column partition, fine — but make sure each column is earning its place.
If you're windowing over already-aggregated data, don't window over the raw table. This is one of the highest-impact transformations you can make.
-- Expensive: windowing over 50M raw rows
SELECT
customer_id,
DATE_TRUNC('month', order_date) AS order_month,
SUM(revenue) OVER (
PARTITION BY customer_id
ORDER BY DATE_TRUNC('month', order_date)
) AS running_monthly_revenue
FROM orders;
-- Better: pre-aggregate first, then window over 200K rows
WITH monthly_revenue AS (
SELECT
customer_id,
DATE_TRUNC('month', order_date) AS order_month,
SUM(revenue) AS monthly_revenue
FROM orders
GROUP BY customer_id, DATE_TRUNC('month', order_date)
)
SELECT
customer_id,
order_month,
SUM(monthly_revenue) OVER (
PARTITION BY customer_id
ORDER BY order_month
) AS running_monthly_revenue
FROM monthly_revenue;
The second query windows over ~200,000 rows instead of 50 million. The sort and computation costs are reduced by orders of magnitude. Common Table Expressions (CTEs) make this pattern clean and readable — use them freely here.
Tip
If you find yourself applying DATE_TRUNC() or other expressions inside a window function's ORDER BY, that's a strong signal you should pre-aggregate first. Expression evaluation in sort keys prevents index usage and adds CPU overhead on every row.
Frame boundaries define which rows within a partition are included in the calculation for each output row. If you've gone deep on this topic, Advanced Window Frame Specifications: ROWS, RANGE, and GROUPS Clauses for Precise Rolling Calculations covers every combination. Here we'll focus on the performance angle.
When you write ORDER BY inside a window function without specifying a frame, most databases apply a default frame of RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. This is rarely what you want for performance.
The RANGE mode treats rows with equal ORDER BY values as a single logical group — it must look ahead to find the boundary of ties. ROWS mode is purely positional and doesn't need to check for ties at all.
-- RANGE: engine must scan forward to find tie boundaries
SUM(revenue) OVER (
PARTITION BY customer_id
ORDER BY order_date -- implicit: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
)
-- ROWS: purely positional, simpler execution
SUM(revenue) OVER (
PARTITION BY customer_id
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
)
If order_date is truly unique per customer (which is common), both produce the same result. But ROWS executes faster because the engine doesn't need to scan ahead for ties. Always specify your frame explicitly. Don't let the database guess.
A sliding window like ROWS BETWEEN 6 PRECEDING AND CURRENT ROW (a 7-day rolling average) is more expensive than ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Why? Because unbounded frames can be computed with a simple accumulator — you just keep adding as you move forward. Sliding windows require maintaining a buffer of the last N rows and removing old values.
PostgreSQL and SQL Server both optimize the unbounded-preceding case to use a running accumulator, making it O(N) per partition. A sliding window is still O(N) but with higher constant factors. For very large windows (30 or 90 days), this can become noticeable.
-- 7-day rolling average: PostgreSQL handles efficiently
AVG(daily_revenue) OVER (
PARTITION BY store_id
ORDER BY sale_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
)
-- 90-day rolling average on hourly data: potentially expensive
-- Consider pre-aggregating to daily before applying the window
AVG(hourly_revenue) OVER (
PARTITION BY store_id
ORDER BY event_hour
ROWS BETWEEN 2159 PRECEDING AND CURRENT ROW -- 90 days * 24 hours
)
For the 90-day hourly case, pre-aggregating to daily first and then applying a 90-row window is dramatically cheaper — and logically equivalent if you want daily averages.
Warning
RANGE with a numeric offset (e.g., RANGE BETWEEN 7 PRECEDING AND CURRENT ROW on a date column) requires the engine to evaluate date arithmetic for every row boundary. On large datasets this is significantly slower than an equivalent ROWS-based frame. Use ROWS unless you genuinely need tie-handling behavior, and pre-convert your dates to integer offsets when possible.
One quick correctness note: ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING computes the same value for every row in a partition — it's equivalent to a partition-level aggregate but written as a window function. If that's what you need, consider whether a GROUP BY subquery joined back to the main table would be more readable and equally performant.
This is where the biggest performance gains live. When an index covers both your filter conditions and your window function's sort requirements, the optimizer can often eliminate the sort operator entirely — turning an O(N log N) operation into an O(N) scan.
For a window function query like this:
SELECT
customer_id,
order_date,
revenue,
SUM(revenue) OVER (
PARTITION BY customer_id
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM orders
WHERE order_status = 'completed';
The ideal index would be:
CREATE INDEX idx_orders_customer_date ON orders (customer_id, order_date)
WHERE order_status = 'completed';
This index does three things simultaneously:
customer_id then order_date, matching the window's PARTITION BY + ORDER BYLet's look at what happens in the execution plan with and without this index.
Without the index (using EXPLAIN ANALYZE in PostgreSQL):
Seq Scan on orders (actual rows=12,400,000)
Filter: (order_status = 'completed')
Sort (actual rows=8,200,000, Sort Method: external merge Disk: 1,824 MB)
Sort Key: customer_id, order_date
WindowAgg (actual rows=8,200,000)
That "external merge Disk: 1,824 MB" is the killer. The sort didn't fit in memory and spilled to disk.
With the index:
Index Scan using idx_orders_customer_date on orders (actual rows=8,200,000)
WindowAgg (actual rows=8,200,000)
The sort operator is gone entirely. The index scan delivers rows in exactly the order the window function needs.
Key insight
The optimizer will only skip the sort if the index column order exactly matches (partition_columns..., order_columns...). The partition columns must come first in the index, followed by the order columns in the same direction. A mismatch in column order or sort direction forces an explicit sort regardless of what indexes exist.
A query with multiple window functions over different partitions or orderings presents a real challenge. Each distinct PARTITION BY + ORDER BY combination requires a separate sort.
SELECT
customer_id,
order_date,
product_id,
revenue,
-- Window 1: per-customer running total
SUM(revenue) OVER (
PARTITION BY customer_id
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS customer_running_total,
-- Window 2: per-product running total
SUM(revenue) OVER (
PARTITION BY product_id
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS product_running_total,
-- Window 3: per-customer rank by revenue (different order!)
RANK() OVER (
PARTITION BY customer_id
ORDER BY revenue DESC
) AS customer_revenue_rank
FROM orders;
This query requires three different sort orders: (customer_id, order_date), (product_id, order_date), and (customer_id, revenue DESC). No single index satisfies all three. The optimizer will execute three separate sort operations.
Strategy: decompose into CTEs when sort orders conflict. You can pre-sort once in a CTE and reuse it across compatible window functions:
WITH ordered_by_customer_date AS (
SELECT *
FROM orders
ORDER BY customer_id, order_date -- explicit pre-sort
)
SELECT
customer_id,
order_date,
revenue,
SUM(revenue) OVER (
PARTITION BY customer_id
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS customer_running_total,
-- This window reuses the same sort order
COUNT(*) OVER (
PARTITION BY customer_id
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS customer_order_count
FROM ordered_by_customer_date;
PostgreSQL's planner is smart enough to recognize when two window functions share the same partition and order specification and will execute them in a single pass. SQL Server's optimizer does the same. The key is that incompatible sort orders always force separate passes.
If your window function reads columns that aren't in the index, the engine must perform a heap lookup (also called a bookmark lookup in SQL Server) for each row — which can be more expensive than a table scan at high row counts.
For the running total query above, if you reference customer_id, order_date, and revenue, your ideal index includes all three as a covering index:
CREATE INDEX idx_orders_customer_date_covering
ON orders (customer_id, order_date)
INCLUDE (revenue, order_status);
The INCLUDE clause (supported in PostgreSQL 11+, SQL Server, and others) adds columns to the index leaf pages without making them part of the sort key. The engine can read revenue directly from the index without touching the heap.
Tip
When optimizing aggregating across groups with SQL window functions, aim for covering indexes that include: (1) all PARTITION BY columns, (2) all ORDER BY columns, and (3) all columns referenced in the window function expression itself. This eliminates both the sort and the heap lookup.
You can't optimize what you can't measure. Let's walk through diagnosing a slow window function query using execution plan analysis.
The detailed mechanics of reading execution plans are covered in Query Profiling and Statistics in SQL: Using EXPLAIN ANALYZE, Buffer Metrics, and Row Estimates to Diagnose Slow Queries. Here we'll focus specifically on window function signatures.
Run your query with EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) in PostgreSQL or check the execution plan in SQL Server Management Studio. Look for these operators:
Sort — Always examine the sort operator when it appears above a WindowAgg. Check:
Sort Method: quicksort means it fit in memory (good), external merge means disk spill (bad)WindowAgg — This is the window function computation. It should appear immediately above the sort. Multiple WindowAgg operators stacked means multiple passes.
Hash Aggregate — If you see this instead of WindowAgg, the optimizer may have chosen a suboptimal plan. Check whether your query was accidentally rewritten.
Buffers hit vs read — High Blocks Read values relative to Blocks Hit means your data isn't in the buffer cache. For repeated window function queries, ensure your indexes and hot table regions fit in shared buffers.
If you see Sort Method: external merge with a disk size in the megabytes or gigabytes, the sort buffer is too small. You have two options:
Option 1: Increase work memory (session-level). This is appropriate for one-off analytical queries.
SET work_mem = '512MB';
-- Now run your window function query
SELECT ...
Option 2: Add an index. This is the correct production fix. Increasing work_mem globally wastes memory on all connections; adding a proper index eliminates the sort entirely for this query pattern.
Warning
Setting work_mem high globally in PostgreSQL is dangerous in production. Every sort in every concurrent session gets that memory allocation. On a server with 100 connections each running sorts, work_mem = 1GB can exhaust RAM. Use session-level settings for analytical workloads and fix the underlying indexing problem for recurring queries.
If your execution plan shows two or three Sort + WindowAgg pairs stacked vertically, you have multiple incompatible window specifications. Each requires its own sort. Your options:
-- Pattern: split incompatible windows into separate CTEs, join results
WITH customer_windows AS (
SELECT
order_id,
customer_id,
SUM(revenue) OVER (
PARTITION BY customer_id ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS customer_running_total,
RANK() OVER (
PARTITION BY customer_id ORDER BY revenue DESC
) AS customer_revenue_rank
FROM orders
),
product_windows AS (
SELECT
order_id,
SUM(revenue) OVER (
PARTITION BY product_id ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS product_running_total
FROM orders
)
SELECT
o.order_id,
o.customer_id,
o.product_id,
o.revenue,
c.customer_running_total,
c.customer_revenue_rank,
p.product_running_total
FROM orders o
JOIN customer_windows c ON o.order_id = c.order_id
JOIN product_windows p ON o.order_id = p.order_id;
This pattern trades a join cost for eliminating redundant sorts within a single pass. Whether it's faster depends on your data volume — always measure both approaches.
For recurring analytical queries — dashboards, scheduled reports — the ultimate optimization is to not execute the window function at query time at all. Pre-compute the results and cache them.
Materialized views are the database-native mechanism for this. In PostgreSQL:
CREATE MATERIALIZED VIEW customer_running_totals AS
SELECT
customer_id,
order_date,
order_id,
revenue,
SUM(revenue) OVER (
PARTITION BY customer_id
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total,
AVG(revenue) OVER (
PARTITION BY customer_id
ORDER BY order_date
ROWS BETWEEN 29 PRECEDING AND CURRENT ROW
) AS rolling_30d_avg
FROM orders
WHERE order_status = 'completed';
CREATE INDEX idx_crt_customer ON customer_running_totals (customer_id, order_date);
Dashboard queries then hit the materialized view directly, executing in milliseconds instead of minutes. You schedule REFRESH MATERIALIZED VIEW CONCURRENTLY customer_running_totals during off-peak hours.
The tradeoff: materialized views are stale between refreshes. For running totals and rolling averages where slight latency is acceptable, this is often the right trade. For real-time dashboards, you need a different architecture. The Query Optimization with Materialized Views lesson covers the full refresh strategy landscape.
Let's put everything together with a realistic scenario. You're building a sales performance dashboard for a retail company. The underlying transactions table has 80 million rows across 500,000 customers and 3 years of history.
The existing query that's causing problems:
-- The query that's killing the dashboard (execution time: 4 minutes 12 seconds)
SELECT
customer_id,
transaction_date,
store_id,
amount,
SUM(amount) OVER (
PARTITION BY customer_id
ORDER BY transaction_date
) AS lifetime_value,
AVG(amount) OVER (
PARTITION BY customer_id
ORDER BY transaction_date
ROWS BETWEEN 89 PRECEDING AND CURRENT ROW
) AS rolling_90d_avg,
RANK() OVER (
PARTITION BY DATE_TRUNC('month', transaction_date), store_id
ORDER BY amount DESC
) AS monthly_store_rank
FROM transactions
WHERE transaction_date >= '2023-01-01';
Step 1: Diagnose. Run EXPLAIN (ANALYZE, BUFFERS). You find:
transaction_date >= '2023-01-01' covers 75% of data)Step 2: Pre-aggregate. The dashboard shows monthly summaries, not individual transactions. Aggregate first.
-- Step 1: Materialize the monthly aggregates
CREATE MATERIALIZED VIEW monthly_customer_transactions AS
SELECT
customer_id,
DATE_TRUNC('month', transaction_date) AS transaction_month,
store_id,
SUM(amount) AS monthly_amount,
COUNT(*) AS transaction_count,
AVG(amount) AS avg_transaction
FROM transactions
WHERE transaction_date >= '2022-01-01' -- broader range for rolling windows
GROUP BY
customer_id,
DATE_TRUNC('month', transaction_date),
store_id;
CREATE INDEX idx_mct_customer_month
ON monthly_customer_transactions (customer_id, transaction_month)
INCLUDE (monthly_amount, store_id);
CREATE INDEX idx_mct_month_store
ON monthly_customer_transactions (transaction_month, store_id, monthly_amount DESC);
Step 3: Rewrite the window query against the aggregate.
-- Optimized query against pre-aggregated data (62M rows → ~180K rows)
WITH lifetime_and_rolling AS (
SELECT
customer_id,
transaction_month,
store_id,
monthly_amount,
SUM(monthly_amount) OVER (
PARTITION BY customer_id
ORDER BY transaction_month
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS lifetime_value,
AVG(monthly_amount) OVER (
PARTITION BY customer_id
ORDER BY transaction_month
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW -- 3-month rolling (quarterly)
) AS rolling_3m_avg
FROM monthly_customer_transactions
),
monthly_store_ranks AS (
SELECT
customer_id,
transaction_month,
store_id,
RANK() OVER (
PARTITION BY transaction_month, store_id
ORDER BY monthly_amount DESC
) AS monthly_store_rank
FROM monthly_customer_transactions
)
SELECT
l.customer_id,
l.transaction_month,
l.store_id,
l.monthly_amount,
l.lifetime_value,
l.rolling_3m_avg,
r.monthly_store_rank
FROM lifetime_and_rolling l
JOIN monthly_store_ranks r
ON l.customer_id = r.customer_id
AND l.transaction_month = r.transaction_month
AND l.store_id = r.store_id
WHERE l.transaction_month >= '2023-01-01';
Step 4: Create the dashboard view.
CREATE MATERIALIZED VIEW sales_dashboard AS
-- The query above
;
REFRESH MATERIALIZED VIEW CONCURRENTLY sales_dashboard;
Results: Dashboard query time drops from 4 minutes 12 seconds to 340 milliseconds — a 74x improvement. The improvements came from: pre-aggregation (80M → 180K rows), index alignment eliminating disk spills, and the CTE decomposition allowing the optimizer to use separate, targeted indexes for each window specification.
Set up this scenario and work through the optimization yourself. Start with the DDL and data generation:
-- Create the base table
CREATE TABLE web_events (
event_id BIGSERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
session_id BIGINT NOT NULL,
event_type VARCHAR(50) NOT NULL,
page_category VARCHAR(50) NOT NULL,
revenue NUMERIC(10,2),
event_time TIMESTAMP NOT NULL
);
-- Generate realistic test data (adjust row count to your environment)
INSERT INTO web_events (user_id, session_id, event_type, page_category, revenue, event_time)
SELECT
(random() * 100000)::INTEGER + 1 AS user_id,
(random() * 1000000)::BIGINT + 1 AS session_id,
(ARRAY['page_view','add_to_cart','purchase','search'])[ceil(random()*4)::INTEGER] AS event_type,
(ARRAY['electronics','clothing','books','home','sports'])[ceil(random()*5)::INTEGER] AS page_category,
CASE WHEN random() > 0.85 THEN (random() * 500)::NUMERIC(10,2) ELSE NULL END AS revenue,
TIMESTAMP '2023-01-01' + (random() * 730) * INTERVAL '1 day'
+ (random() * 86400) * INTERVAL '1 second' AS event_time
FROM generate_series(1, 5000000);
-- Analyze the table so the planner has statistics
ANALYZE web_events;
Task 1: Write a query that computes for each user: their running count of purchase events, their running total revenue, and their rank within each page_category by total revenue. Run EXPLAIN (ANALYZE, BUFFERS) and note the execution time, sort methods, and whether any disk spills occur.
Task 2: Identify the optimal index structure for this query and create it. Re-run the query and compare the execution plan. Did the sort operator disappear? Did disk spills go away?
Task 3: The stakeholder now wants a 7-day rolling event count per user. Rewrite the query to pre-aggregate to daily counts before applying the window function. Compare the performance of the raw-row approach vs. the pre-aggregated approach.
Task 4: Create a materialized view of the final result. Add an appropriate index to the materialized view. Write a query against the materialized view that filters to a specific page_category and measure its execution time.
PARTITION BY doesn't reduce the rows the engine processes — it only groups them for window computation. If you want to limit the data, put a WHERE clause on the outer query or in a CTE. Adding more columns to PARTITION BY does not make the query faster; it makes it more complex and harder to index.
If you write ORDER BY DATE_TRUNC('month', event_time) inside your window function, the optimizer cannot use an index on event_time for sorting — the expression must be evaluated per row before sorting. Create a computed/generated column or pre-aggregate to avoid expression evaluation in sort keys.
-- Blocks index usage
SUM(revenue) OVER (PARTITION BY user_id ORDER BY DATE_TRUNC('day', event_time))
-- Index-friendly: add a generated column
ALTER TABLE web_events ADD COLUMN event_date DATE
GENERATED ALWAYS AS (event_time::DATE) STORED;
CREATE INDEX idx_events_user_date ON web_events (user_id, event_date);
-- Now this can use the index
SUM(revenue) OVER (PARTITION BY user_id ORDER BY event_date)
Each distinct window specification requires a sort pass. Five window functions with five different PARTITION BY + ORDER BY combinations means five sorts over your entire dataset. Decompose into CTEs and join the results, or use a materialized view as an intermediate layer.
The default frame RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW is almost always what you want semantically, but ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW is faster when there are no ties (or when ties don't affect your intended semantics). Always be explicit and prefer ROWS unless you specifically need tie-group behavior. This is an easy win that requires just one extra line of SQL.
If your final output only needs rows where event_type = 'purchase', but you're computing a window function over all event types and then filtering, you're sorting millions of extra rows.
-- Sorts all 5M events, then discards most of them
SELECT * FROM (
SELECT
*,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY event_time) AS rn
FROM web_events
) sub
WHERE event_type = 'purchase';
-- Pre-filter, then window over only purchase events
SELECT
*,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY event_time) AS rn
FROM web_events
WHERE event_type = 'purchase';
This seems obvious but is frequently overlooked. Make sure your filters are in the right position — before the window, not after it. The database performance tuning and advanced indexing strategies apply here: pre-filtering is always cheaper than post-filtering.
If you added an index but the optimizer still chooses a sequential scan, check:
ANALYZE table_name to update planner statistics.WHERE clause can help.(order_date, customer_id) does not satisfy PARTITION BY customer_id ORDER BY order_date.INTEGER but the column is BIGINT, the index may not be used. Check data types match exactly.Window function performance comes down to three pillars working together: smart partitioning (smaller partitions, pre-aggregated data, no unnecessary columns), explicit frame boundaries (use ROWS over RANGE whenever semantics allow, always be explicit), and index alignment (leading partition columns, followed by order columns, covering the window expression).
The diagnostic workflow is straightforward: run EXPLAIN ANALYZE with buffer metrics, find the sort operators, check for disk spills, and trace back to whether an index could eliminate the sort. If the sort can't be eliminated (because of incompatible window specs), decompose into CTEs and pre-sort explicitly so the optimizer can reuse the sorted result.
For production systems with recurring analytical queries, materialized views represent the ultimate optimization — compute once, serve many times. Pair them with a refresh strategy appropriate to your latency tolerance.
Where to go next:
GROUPS mode, exclusion clauses, and edge cases.