|$ curl https://forge-ai.dev/api/markdown?path=docs/databases/sql-window-functions
$cat docs/sql-window-functions.md
updated Recentlyยท45 min readยทpublished

SQL Window Functions

โ—†SQLโ—†Databasesโ—†Advancedโ—†Advanced๐ŸŽฏFree Tools
Introduction

Window functions perform calculations across a set of rows related to the current row. Unlike aggregate functions, window functions do not collapse rows โ€” they return a value for every row in the result set. This makes them incredibly powerful for analytics, ranking, and time-series analysis.

Window functions use the OVER clause to define the window (set of rows) to operate on. You can partition rows into groups, order them, and define a frame (subset of rows) for each calculation.

ROW_NUMBER, RANK, and DENSE_RANK

These functions assign numbers to rows based on their order. The key difference is how they handle ties (rows with the same values).

row-number-rank.sql
SQL
1-- ROW_NUMBER: Unique number for each row (no ties)
2SELECT
3 name,
4 salary,
5 ROW_NUMBER() OVER (ORDER BY salary DESC) AS row_num
6FROM employees;
7
8-- RANK: Same rank for ties, gaps after ties
9-- If two people have the same salary, they get the same rank
10-- The next rank skips numbers (1, 2, 2, 4)
11SELECT
12 name,
13 salary,
14 RANK() OVER (ORDER BY salary DESC) AS rank
15FROM employees;
16
17-- DENSE_RANK: Same rank for ties, no gaps
18-- If two people have the same salary, they get the same rank
19-- The next rank is consecutive (1, 2, 2, 3)
20SELECT
21 name,
22 salary,
23 DENSE_RANK() OVER (ORDER BY salary DESC) AS dense_rank
24FROM employees;
25
26-- Compare all three side by side
27SELECT
28 name,
29 salary,
30 ROW_NUMBER() OVER (ORDER BY salary DESC) AS row_num,
31 RANK() OVER (ORDER BY salary DESC) AS rank,
32 DENSE_RANK() OVER (ORDER BY salary DESC) AS dense_rank
33FROM employees
34ORDER BY salary DESC;
35
36-- PARTITION BY: Rank within each department
37SELECT
38 name,
39 department,
40 salary,
41 ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS dept_rank
42FROM employees;
43
44-- Find top 3 earners in each department
45SELECT *
46FROM (
47 SELECT
48 name,
49 department,
50 salary,
51 ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS dept_rank
52 FROM employees
53) ranked
54WHERE dept_rank <= 3;
โ„น

info

Use ROW_NUMBER when you need a unique identifier for each row. Use RANK when you want gaps after ties. Use DENSE_RANK when you want consecutive ranks without gaps.
LAG and LEAD โ€” Accessing Previous/Next Rows

LAG accesses data from a previous row in the same result set. LEAD accesses data from a subsequent row. They are essential for time-series analysis and comparing consecutive values.

lag-lead.sql
SQL
1-- LAG: Get previous row's value
2SELECT
3 date,
4 revenue,
5 LAG(revenue) OVER (ORDER BY date) AS prev_day_revenue
6FROM daily_revenue;
7
8-- LEAD: Get next row's value
9SELECT
10 date,
11 revenue,
12 LEAD(revenue) OVER (ORDER BY date) AS next_day_revenue
13FROM daily_revenue;
14
15-- Calculate day-over-day change
16SELECT
17 date,
18 revenue,
19 LAG(revenue) OVER (ORDER BY date) AS prev_revenue,
20 revenue - LAG(revenue) OVER (ORDER BY date) AS daily_change,
21 ROUND(
22 (revenue - LAG(revenue) OVER (ORDER BY date))
23 / NULLIF(LAG(revenue) OVER (ORDER BY date), 0) * 100,
24 2
25 ) AS growth_pct
26FROM daily_revenue;
27
28-- LAG with offset and default value
29SELECT
30 date,
31 revenue,
32 LAG(revenue, 7, 0) OVER (ORDER BY date) AS prev_week_revenue
33FROM daily_revenue;
34
35-- Compare with same day last week
36SELECT
37 date,
38 revenue,
39 LAG(revenue, 7) OVER (ORDER BY date) AS same_day_last_week
40FROM daily_revenue;
41
42-- LAG with PARTITION BY: Compare within each product
43SELECT
44 product_id,
45 date,
46 sales,
47 LAG(sales) OVER (PARTITION BY product_id ORDER BY date) AS prev_day_sales
48FROM product_sales;
49
50-- Find users who upgraded their plan
51SELECT
52 user_id,
53 plan,
54 created_at,
55 LAG(plan) OVER (PARTITION BY user_id ORDER BY created_at) AS prev_plan,
56 CASE
57 WHEN plan != LAG(plan) OVER (PARTITION BY user_id ORDER BY created_at)
58 THEN 'Plan changed'
59 ELSE 'No change'
60 END AS status
61FROM subscriptions;
๐Ÿ“

note

LAG and LEAD return NULL for the first/last row when there is no previous/next row. Use the default value parameter to handle this: LAG(value, offset, default).
FIRST_VALUE and LAST_VALUE

FIRST_VALUE returns the first value in the window frame. LAST_VALUE returns the last value. These are useful for comparing each row against the first or last row in a partition.

first-last-value.sql
SQL
1-- FIRST_VALUE: Compare each row to the first row in partition
2SELECT
3 name,
4 department,
5 salary,
6 FIRST_VALUE(salary) OVER (
7 PARTITION BY department
8 ORDER BY salary DESC
9 ) AS dept_max_salary,
10 salary - FIRST_VALUE(salary) OVER (
11 PARTITION BY department
12 ORDER BY salary DESC
13 ) AS diff_from_max
14FROM employees;
15
16-- LAST_VALUE: Compare each row to the last row in partition
17-- Note: Need to define frame to see the actual last row
18SELECT
19 name,
20 department,
21 salary,
22 LAST_VALUE(salary) OVER (
23 PARTITION BY department
24 ORDER BY salary DESC
25 ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
26 ) AS dept_min_salary
27FROM employees;
28
29-- Calculate percentage of max salary in department
30SELECT
31 name,
32 department,
33 salary,
34 ROUND(
35 salary / FIRST_VALUE(salary) OVER (
36 PARTITION BY department
37 ORDER BY salary DESC
38 ) * 100,
39 2
40 ) AS pct_of_max
41FROM employees;
42
43-- Compare to first and last order dates
44SELECT
45 order_id,
46 created_at,
47 FIRST_VALUE(created_at) OVER (
48 PARTITION BY user_id
49 ORDER BY created_at
50 ) AS first_order_date,
51 LAST_VALUE(created_at) OVER (
52 PARTITION BY user_id
53 ORDER BY created_at
54 ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
55 ) AS last_order_date
56FROM orders;
Aggregate Window Functions

Aggregate functions (SUM, AVG, COUNT, MIN, MAX) can also be used as window functions. Unlike GROUP BY, they do not collapse rows โ€” they return the aggregate value for every row.

aggregate-window.sql
SQL
1-- SUM: Running total
2SELECT
3 date,
4 revenue,
5 SUM(revenue) OVER (ORDER BY date) AS running_total
6FROM daily_revenue;
7
8-- SUM with PARTITION BY: Running total per product
9SELECT
10 product_id,
11 date,
12 sales,
13 SUM(sales) OVER (PARTITION BY product_id ORDER BY date) AS product_running_total
14FROM product_sales;
15
16-- AVG: Moving average (last 7 days)
17SELECT
18 date,
19 revenue,
20 AVG(revenue) OVER (
21 ORDER BY date
22 ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
23 ) AS moving_avg_7d
24FROM daily_revenue;
25
26-- COUNT: Running count
27SELECT
28 date,
29 COUNT(*) OVER (ORDER BY date) AS cumulative_count
30FROM daily_revenue;
31
32-- MIN/MAX: Compare to overall min/max
33SELECT
34 name,
35 salary,
36 MIN(salary) OVER () AS company_min_salary,
37 MAX(salary) OVER () AS company_max_salary,
38 salary - MIN(salary) OVER () AS above_min
39FROM employees;
40
41-- MIN/MAX with PARTITION BY: Compare to department min/max
42SELECT
43 name,
44 department,
45 salary,
46 MIN(salary) OVER (PARTITION BY department) AS dept_min,
47 MAX(salary) OVER (PARTITION BY department) AS dept_max,
48 salary - AVG(salary) OVER (PARTITION BY department) AS diff_from_avg
49FROM employees;
50
51-- Percentage of total
52SELECT
53 product_id,
54 sales,
55 ROUND(
56 sales / SUM(sales) OVER () * 100,
57 2
58 ) AS pct_of_total
59FROM product_sales;
60
61-- Percentage of partition total
62SELECT
63 product_id,
64 category,
65 sales,
66 ROUND(
67 sales / SUM(sales) OVER (PARTITION BY category) * 100,
68 2
69 ) AS pct_of_category
70FROM product_sales;
Window Frames

Window frames define which rows are included in the calculation for each row. You can use ROWS (physical rows) or RANGE (logical range based on values).

window-frames.sql
SQL
1-- ROWS frame: Physical rows relative to current row
2SELECT
3 date,
4 revenue,
5 SUM(revenue) OVER (
6 ORDER BY date
7 ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
8 ) AS sum_last_3_days
9FROM daily_revenue;
10
11-- ROWS frame: Centered window
12SELECT
13 date,
14 revenue,
15 AVG(revenue) OVER (
16 ORDER BY date
17 ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING
18 ) AS centered_avg
19FROM daily_revenue;
20
21-- ROWS frame: All rows from start to current
22SELECT
23 date,
24 revenue,
25 SUM(revenue) OVER (
26 ORDER BY date
27 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
28 ) AS cumulative_sum
29FROM daily_revenue;
30
31-- ROWS frame: All rows in partition
32SELECT
33 date,
34 revenue,
35 SUM(revenue) OVER (
36 ORDER BY date
37 ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
38 ) AS total_sum
39FROM daily_revenue;
40
41-- RANGE frame: Logical range (same values treated as one)
42SELECT
43 date,
44 revenue,
45 SUM(revenue) OVER (
46 ORDER BY date
47 RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW
48 ) AS sum_last_7_days
49FROM daily_revenue;
50
51-- GROUPS frame: Groups of rows with same value (PostgreSQL 11+)
52SELECT
53 date,
54 revenue,
55 SUM(revenue) OVER (
56 ORDER BY date
57 GROUPS BETWEEN 2 PRECEDING AND CURRENT ROW
58 ) AS sum_last_3_groups
59FROM daily_revenue;
60
61-- EXCLUDE: Exclude specific rows from frame
62SELECT
63 date,
64 revenue,
65 AVG(revenue) OVER (
66 ORDER BY date
67 ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING
68 EXCLUDE CURRENT ROW
69 ) AS avg_excluding_self
70FROM daily_revenue;
โš 

warning

The default frame when using ORDER BY is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. This can produce unexpected results with duplicates. Always specify ROWS explicitly for clarity.
Practical Examples
practical-examples.sql
SQL
1-- 1. Top N per group (using ROW_NUMBER)
2SELECT *
3FROM (
4 SELECT
5 name,
6 department,
7 salary,
8 ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rank
9 FROM employees
10) ranked
11WHERE rank <= 3;
12
13-- 2. Deduplication (keep first occurrence)
14DELETE FROM users
15WHERE id NOT IN (
16 SELECT MIN(id)
17 FROM users
18 GROUP BY email
19);
20
21-- Or using ROW_NUMBER
22WITH ranked AS (
23 SELECT
24 id,
25 ROW_NUMBER() OVER (PARTITION BY email ORDER BY created_at) AS rn
26 FROM users
27)
28DELETE FROM users
29WHERE id IN (SELECT id FROM ranked WHERE rn > 1);
30
31-- 3. Gaps and islands (find consecutive date ranges)
32SELECT
33 MIN(date) AS start_date,
34 MAX(date) AS end_date,
35 COUNT(*) AS days_count
36FROM (
37 SELECT
38 date,
39 date - (ROW_NUMBER() OVER (ORDER BY date))::int AS grp
40 FROM daily_sales
41) t
42GROUP BY grp
43ORDER BY start_date;
44
45-- 4. Session detection (group events within 30 minutes)
46SELECT
47 user_id,
48 event_time,
49 SUM(new_session) OVER (PARTITION BY user_id ORDER BY event_time) AS session_id
50FROM (
51 SELECT
52 user_id,
53 event_time,
54 CASE
55 WHEN event_time - LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time) > INTERVAL '30 minutes'
56 THEN 1
57 ELSE 0
58 END AS new_session
59 FROM events
60) t;
61
62-- 5. Percentile ranking
63SELECT
64 name,
65 salary,
66 PERCENT_RANK() OVER (ORDER BY salary) AS percentile,
67 NTILE(4) OVER (ORDER BY salary) AS quartile
68FROM employees;
69
70-- 6. Cumulative distribution
71SELECT
72 name,
73 salary,
74 CUME_DIST() OVER (ORDER BY salary) AS cume_dist
75FROM employees;
Summary
FunctionPurposeExample
ROW_NUMBERUnique number for each rowROW_NUMBER() OVER (ORDER BY salary DESC)
RANKRank with gaps after tiesRANK() OVER (ORDER BY salary DESC)
DENSE_RANKRank without gapsDENSE_RANK() OVER (ORDER BY salary DESC)
LAGPrevious row valueLAG(revenue) OVER (ORDER BY date)
LEADNext row valueLEAD(revenue) OVER (ORDER BY date)
FIRST_VALUEFirst value in partitionFIRST_VALUE(salary) OVER (PARTITION BY dept ORDER BY salary DESC)
LAST_VALUELast value in partitionLAST_VALUE(salary) OVER (PARTITION BY dept ORDER BY salary DESC)
SUM/AVG/COUNTAggregate over windowSUM(sales) OVER (PARTITION BY product ORDER BY date)