SQL window functions: 10 patterns for real analysis
Short answer: Window functions calculate across rows related to the current row without collapsing them the way
GROUP BYdoes. A handful of patterns (ranking, deduplication, running totals,LAG, percent of total, bucketing and gaps-and-islands) cover most everyday analytical work in a single readable query.
If you only learn one “advanced” SQL feature, make it window functions. After years of writing SQL for marketing, sales and payments teams, I find that a small set of window patterns comes up again and again. This guide walks through ten of them, plus a payments example, with code you can adapt.
How does window function syntax work?
function_name(expression) OVER (
PARTITION BY column_a -- restart the calculation for each group
ORDER BY column_b -- define the row order inside the group
ROWS BETWEEN ... AND ... -- optional: define the frame
)
PARTITION BYsplits rows into groups (likeGROUP BY, but the rows are kept).ORDER BYsets the order inside each partition.- The frame clause (
ROWS BETWEEN) limits which rows the function sees, for example “the last 7 rows”.
The examples use PostgreSQL syntax. Most work unchanged, or with small tweaks, in BigQuery, Snowflake, SQL Server and MySQL 8+. Where a feature is not portable, I say so.
Source: PostgreSQL documentation: Window Functions tutorial.
1. How do I rank rows and get the top N per group?
Use cases: top N per group, best product per category, latest record per customer.
SELECT *
FROM (
SELECT
customer_id,
order_id,
order_date,
amount,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY order_date DESC
) AS rn
FROM orders
) t
WHERE rn = 1; -- most recent order per customer
The three ranking functions differ when there are ties:
| Function | Ties | Example output |
|---|---|---|
ROW_NUMBER() |
Always unique | 1, 2, 3, 4 |
RANK() |
Same rank, gaps after | 1, 2, 2, 4 |
DENSE_RANK() |
Same rank, no gaps | 1, 2, 2, 3 |
Tip: Snowflake, BigQuery and Databricks support
QUALIFY, so you can skip the subquery:QUALIFY ROW_NUMBER() OVER (...) = 1.
Source: Snowflake documentation: QUALIFY.
2. How do I remove duplicates?
Use cases: duplicate webhook events, repeated payment attempts, double-loaded ETL rows.
WITH ranked AS (
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY transaction_id
ORDER BY received_at DESC
) AS rn
FROM payment_events
)
SELECT * FROM ranked WHERE rn = 1;
This keeps the latest version of each transaction. It is the cleanest deduplication pattern I know, and it lets you choose which duplicate survives through the ORDER BY.
3. How do I calculate running totals?
Use cases: cumulative revenue, month-to-date sales, budget burn-down.
SELECT
order_date,
daily_revenue,
SUM(daily_revenue) OVER (
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS cumulative_revenue
FROM daily_sales;
Add PARTITION BY DATE_TRUNC('month', order_date) to reset the total each month and get month-to-date revenue.
4. How do I smooth a noisy metric with a moving average?
Use cases: smoothing daily metrics, trend detection, forecasting baselines.
SELECT
order_date,
daily_revenue,
AVG(daily_revenue) OVER (
ORDER BY order_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS revenue_7d_avg
FROM daily_sales;
A 7-day average removes the weekday and weekend pattern that makes daily charts hard to read. This frame counts rows, so it assumes one row per day. If days can be missing, fill the gaps first.
5. How do I compare to the previous period with LAG and LEAD?
Use cases: month-over-month growth, time between purchases, churn signals.
SELECT
month,
revenue,
LAG(revenue) OVER (ORDER BY month) AS prev_month,
ROUND(
100.0 * (revenue - LAG(revenue) OVER (ORDER BY month))
/ NULLIF(LAG(revenue) OVER (ORDER BY month), 0),
2
) AS mom_growth_pct
FROM monthly_revenue;
LAG looks backward and LEAD looks forward. Both accept an offset and a default value: LAG(revenue, 12, 0) returns the same month last year, or 0 when there is no such row.
To measure the time between orders per customer:
SELECT
customer_id,
order_date,
order_date - LAG(order_date) OVER (
PARTITION BY customer_id ORDER BY order_date
) AS days_since_last_order
FROM orders;
6. How do I calculate percent of total?
Use cases: revenue share by channel, payment method mix, market share.
SELECT
payment_method,
SUM(amount) AS revenue,
ROUND(100.0 * SUM(amount) / SUM(SUM(amount)) OVER (), 2) AS pct_of_total
FROM transactions
GROUP BY payment_method
ORDER BY revenue DESC;
Note SUM(SUM(amount)) OVER (). Window functions run after GROUP BY, so you can wrap an aggregate inside a window function to get the grand total on every row.
7. How do I split customers into segments with NTILE?
Use cases: customer segmentation, RFM scoring, decile analysis.
SELECT
customer_id,
lifetime_value,
NTILE(10) OVER (ORDER BY lifetime_value DESC) AS decile
FROM customer_ltv;
Decile 1 holds your top 10% of customers by lifetime value. From there you can check how much of your revenue the top decile drives.
8. How do I get the first or last value in a group?
Use cases: first-touch attribution, cohort assignment, comparing every row to a baseline.
SELECT
customer_id,
order_date,
amount,
FIRST_VALUE(acquisition_channel) OVER (
PARTITION BY customer_id
ORDER BY order_date
) AS first_touch_channel
FROM orders;
Gotcha: with an
ORDER BY, the default frame ends at the current row, soLAST_VALUEoften just returns the current row’s value. SpecifyROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWINGwhen you want the true last value.
Source: PostgreSQL documentation: Window Function Processing.
9. How do I build a cohort retention table?
Use cases: SaaS and e-commerce retention, repeat purchase rate.
WITH first_orders AS (
SELECT
customer_id,
DATE_TRUNC('month', order_date) AS order_month,
MIN(DATE_TRUNC('month', order_date)) OVER (
PARTITION BY customer_id
) AS cohort_month
FROM orders
)
SELECT
cohort_month,
(DATE_PART('year', order_month) - DATE_PART('year', cohort_month)) * 12
+ DATE_PART('month', order_month) - DATE_PART('month', cohort_month)
AS months_since_first_order,
COUNT(DISTINCT customer_id) AS active_customers
FROM first_orders
GROUP BY 1, 2
ORDER BY 1, 2;
MIN() OVER (PARTITION BY customer_id) assigns each customer to a cohort without a separate self-join.
10. How do I find streaks and sessions (gaps and islands)?
Use cases: user sessions, consecutive login streaks, uninterrupted subscription periods, outage windows.
The classic trick: subtract a row number from the date. Rows in the same consecutive streak end up with the same result.
WITH days AS (
SELECT DISTINCT
user_id,
activity_date::date AS d
FROM user_activity
),
grouped AS (
SELECT
user_id,
d,
d - (ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY d))::int AS grp
FROM days
)
SELECT
user_id,
MIN(d) AS streak_start,
MAX(d) AS streak_end,
COUNT(*) AS streak_length
FROM grouped
GROUP BY user_id, grp
ORDER BY user_id, streak_start;
The date - integer arithmetic is PostgreSQL syntax. Other databases need a date function such as DATEADD or DATE_SUB.
For sessions based on inactivity, use LAG to flag gaps longer than 30 minutes, then take a running SUM of those flags to build a session ID.
How can I use window functions for payment decline analysis?
If you work in e-commerce payments, window functions are well suited to decline analysis. This query calculates the 7-day rolling decline rate per payment provider:
WITH daily AS (
SELECT
provider,
DATE(created_at) AS day,
COUNT(*) AS attempts,
COUNT(*) FILTER (WHERE status = 'declined') AS declines
FROM payment_attempts
GROUP BY 1, 2
)
SELECT
provider,
day,
ROUND(
100.0 * SUM(declines) OVER w / NULLIF(SUM(attempts) OVER w, 0),
2
) AS decline_rate_7d
FROM daily
WINDOW w AS (
PARTITION BY provider
ORDER BY day
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
)
ORDER BY provider, day;
Two details are worth noting. First, the named window (WINDOW w AS ...) avoids repeating the same OVER (...) clause and keeps the query readable. Second, FILTER (WHERE ...) is PostgreSQL syntax; elsewhere, use SUM(CASE WHEN status = 'declined' THEN 1 ELSE 0 END). Also make sure each provider has a row for every day, or the 7-row frame will span more than 7 days.
What are the performance tips for window functions?
- Filter early. Window functions run after
WHEREandGROUP BY, so reduce your row count first, for example with a CTE. - Reuse windows. Queries that share the same
PARTITION BYandORDER BYcan often share one sort in the query plan. Named windows keep those definitions consistent. - Be explicit about the frame. When you add an
ORDER BY, the default frame isRANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, which treats tied rows differently fromROWS. Write the frame out. - Consider indexes on partition and order columns for large tables in a transactional database. Check the query plan to confirm it helps.
- You cannot filter on a window result in
WHERE. Wrap the query in a CTE or subquery, or useQUALIFYwhere supported.
Cheat sheet
| Goal | Function |
|---|---|
| Top N per group | ROW_NUMBER, RANK, DENSE_RANK |
| Remove duplicates | ROW_NUMBER() ... WHERE rn = 1 |
| Running total | SUM() OVER (ORDER BY ...) |
| Moving average | AVG() OVER (ROWS BETWEEN n PRECEDING ...) |
| Compare to previous row | LAG, LEAD |
| Share of total | SUM(x) / SUM(SUM(x)) OVER () |
| Segments and deciles | NTILE |
| First or last in group | FIRST_VALUE, LAST_VALUE |
| Streaks and sessions | ROW_NUMBER difference trick |
Frequently asked questions
What is the difference between GROUP BY and a window function?
GROUP BY collapses rows into one row per group. A window function calculates across a group of rows but keeps every row in the result.
Can I use a window function in a WHERE clause?
No. Window functions are evaluated after WHERE. Put the calculation in a CTE or subquery and filter in the outer query, or use QUALIFY in databases that support it.
What is the difference between ROWS and RANGE frames?
ROWS counts physical rows. RANGE groups rows with equal ORDER BY values (peers) together, so ties are treated as one unit. Use ROWS when you want predictable results.
Do window functions work in MySQL? Yes, from MySQL 8.0. Older versions do not support them.
Sources
- PostgreSQL documentation: Window Functions tutorial
- PostgreSQL documentation: Window Function Processing
- PostgreSQL documentation: Window Functions reference
- Snowflake documentation: QUALIFY
- MySQL 8.0 Reference Manual: Window Functions
Last reviewed: 19 September 2026.