sum(amount) OVER ( PARTITION BY user_id -- split rows into independent groups (optional) ORDER BY created_at -- order inside each partition (needed for ranking/LAG/running totals) ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW -- frame: which rows feed the aggregate)
Part
Purpose
If Omitted
PARTITION BY
Restart the calculation per group
Whole result is one partition
ORDER BY
Defines sequence for rank/offset/running calcs
Rows unordered; aggregate covers whole partition
Frame
Subset of the partition for aggregates/*_VALUE
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW when ORDER BY present, else whole partition
WINDOW w AS (…)
Name a window to reuse
Repeat the definition
-- GROUP BY collapses; a window keeps every row and adds the group valueSELECT name, department, salary, round(avg(salary) OVER (PARTITION BY department)) AS dept_avg, round(salary - avg(salary) OVER (PARTITION BY department)) AS diff_from_avg, round(100 * salary / sum(salary) OVER (), 1) AS pct_of_payrollFROM employeesORDER BY department, salary DESC;
Ranking Functions
ROW_NUMBER vs RANK vs DENSE_RANK
Function
Ties
Gaps After Ties
Salaries 150, 150, 120
ROW_NUMBER()
Arbitrary unique numbers
n/a
1, 2, 3
RANK()
Same rank
Yes
1, 1, 3
DENSE_RANK()
Same rank
No
1, 1, 2
PERCENT_RANK()
(rank − 1) / (rows − 1)
—
0, 0, 1
NTILE(n)
Splits into n near-equal buckets
—
1, 1, 2 (n = 2)
SELECT name, department, salary, ROW_NUMBER() OVER w AS row_num, RANK() OVER w AS rnk, DENSE_RANK() OVER w AS dense_rnk, NTILE(2) OVER w AS halfFROM employeesWHERE department = 'Engineering'WINDOW w AS (ORDER BY salary DESC)ORDER BY salary DESC, name;
Top-N per Group
-- Top 2 earners per department (window must be filtered in an outer query)SELECT department, name, salaryFROM ( SELECT department, name, salary, DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS rnk FROM employees) rankedWHERE rnk <= 2ORDER BY department, rnk, name;
ROW_NUMBER: Exactly N rows per group, ties broken arbitrarily (add a tie-breaker column)
RANK / DENSE_RANK: May return more than N rows when ties exist
Alternatives: DISTINCT ON for N = 1, LATERAL … LIMIT N for large tables with an index (see Joins)
Classic: Nth Highest Salary
-- 2nd highest distinct salary overall (NULL if it doesn't exist)SELECT (SELECT DISTINCT salary FROM employees ORDER BY salary DESC OFFSET 1 LIMIT 1) AS second_highest;-- With DENSE_RANK — generalises to "Nth per department"SELECT department, name, salaryFROM (SELECT *, DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS r FROM employees) tWHERE r = 2;
Deduplication
-- Keep only the latest payment attempt per order; ROW_NUMBER marks the duplicatesSELECT order_id, id AS payment_id, status, paid_atFROM ( SELECT p.*, ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY paid_at DESC, id DESC) AS rn FROM payments p) tWHERE rn = 1 AND order_id IN (3, 8)ORDER BY order_id;-- Delete duplicates pattern (here: would delete earlier attempts — rolled back)BEGIN;DELETE FROM paymentsWHERE id IN ( SELECT id FROM ( SELECT id, ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY paid_at DESC) AS rn FROM payments ) t WHERE rn > 1);ROLLBACK;
-- Each customer's orders with previous/next order date and gap in daysSELECT user_id, id, created_at::date AS order_date, LAG(created_at::date) OVER w AS prev_order, LEAD(created_at::date) OVER w AS next_order, created_at::date - LAG(created_at::date) OVER w AS days_since_prev, FIRST_VALUE(created_at::date) OVER w AS first_orderFROM ordersWHERE user_id IN (1, 2)WINDOW w AS (PARTITION BY user_id ORDER BY created_at)ORDER BY user_id, created_at;
The LAST_VALUE Gotcha
Problem: With ORDER BY, the default frame ends at the current row, so LAST_VALUE returns the current row’s value
Fix: Extend the frame to UNBOUNDED FOLLOWING (or use FIRST_VALUE with reversed order)
SELECT name, salary, LAST_VALUE(name) OVER (ORDER BY salary) AS wrong_lowest_paid_is_me, LAST_VALUE(name) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS highest_paidFROM employeesWHERE department = 'Sales'ORDER BY salary;
Aggregate Windows: Running Totals and Moving Averages
WITH daily AS ( SELECT o.created_at::date AS day, sum(oi.quantity * oi.unit_price) AS revenue FROM orders o JOIN order_items oi ON oi.order_id = o.id WHERE o.status IN ('paid', 'shipped', 'delivered') GROUP BY 1)SELECT day, revenue, sum(revenue) OVER (ORDER BY day) AS running_total, round(avg(revenue) OVER (ORDER BY day ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 2) AS moving_avg_last_3, sum(revenue) OVER (PARTITION BY date_trunc('month', day) ORDER BY day) AS month_to_date, round(100 * revenue / sum(revenue) OVER (), 1) AS pct_of_totalFROM dailyORDER BY day;
Frames: ROWS vs RANGE vs GROUPS
Frame Modes
Mode
Frame Measured In
Ties (Peers)
Example
ROWS
Physical rows
Each row separately
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW = last 3 rows
RANGE
Value distance on the ORDER BY key
Peers included together
RANGE BETWEEN interval '7 days' PRECEDING AND CURRENT ROW = last 7 days
GROUPS
Peer groups
Groups of ties
GROUPS BETWEEN 1 PRECEDING AND CURRENT ROW
Frame Bounds
UNBOUNDED PRECEDING / FOLLOWING: Partition start / end
n PRECEDING / FOLLOWING: Offset (rows, value, or groups depending on mode)
CURRENT ROW: In RANGE/GROUPS this means “the current row and its peers”
EXCLUDE CURRENT ROW / TIES / GROUP: Postgres 11+ frame exclusion
ROWS vs RANGE With Ties
-- Engineering has two 150000 salaries: RANGE treats them as peers and adds both at onceSELECT name, salary, sum(salary) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS rows_running, sum(salary) OVER (ORDER BY salary RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS range_runningFROM employeesWHERE department = 'Engineering'ORDER BY salary, name;
Takeaway: The default frame is RANGE, so a “running total” over a non-unique key jumps on ties. Use ROWS (or add a unique tie-breaker to ORDER BY) for a strict row-by-row running sum
RANGE With an Interval: Rolling Time Windows
-- Revenue in the 30 days up to and including each order (calendar-based, not row-based)SELECT o.id, o.created_at::date, sum(t.total) OVER (ORDER BY o.created_at RANGE BETWEEN interval '30 days' PRECEDING AND CURRENT ROW) AS rolling_30dFROM orders oJOIN (SELECT order_id, sum(quantity * unit_price) AS total FROM order_items GROUP BY order_id) t ON t.order_id = o.idWHERE o.status <> 'cancelled'ORDER BY o.created_at;
Gaps and Islands
Problem: Group consecutive runs (e.g. consecutive months with an order, login streaks)
Trick: value − ROW_NUMBER() is constant within a consecutive run
-- Streaks of consecutive months in which Asha placed an orderWITH months AS ( SELECT DISTINCT date_trunc('month', created_at)::date AS m FROM orders WHERE user_id = 1), grp AS ( SELECT m, m - (ROW_NUMBER() OVER (ORDER BY m) * interval '1 month') AS island FROM months)SELECT min(m) AS streak_start, max(m) AS streak_end, count(*) AS monthsFROM grpGROUP BY islandORDER BY streak_start;
Filtering on Window Results
-- ERROR: window functions are not allowed in WHERE-- SELECT * FROM employees WHERE ROW_NUMBER() OVER (ORDER BY salary DESC) <= 3;-- Wrap it (CTE or subquery)WITH ranked AS ( SELECT name, salary, ROW_NUMBER() OVER (ORDER BY salary DESC) AS rn FROM employees)SELECT name, salary FROM ranked WHERE rn <= 3;
PG 15+ Note: For outer filters like WHERE rn <= N on ROW_NUMBER/RANK/count, the planner adds a “Run Condition” and stops evaluating the window early, so the wrapper is efficient
Interview Questions
“ROW_NUMBER vs RANK vs DENSE_RANK?”: Unique numbers vs gaps after ties vs no gaps
“Top 3 per category?”: Ranking window in a subquery, filter rn <= 3 outside
“Running total?”: sum(x) OVER (ORDER BY …); use a ROWS frame if the key has ties
“Month-over-month growth?”: LAG(revenue) OVER (ORDER BY month); divide with NULLIF(…, 0)
“Why does LAST_VALUE return the current row?”: Default frame ends at the current row — widen it
“Window vs GROUP BY?”: Window keeps detail rows; GROUP BY collapses them
Summary
OVER(): PARTITION BY groups, ORDER BY sequences, frame selects rows for aggregates
Ranking: ROW_NUMBER (unique), RANK (gaps), DENSE_RANK (no gaps), NTILE (buckets)
Offsets: LAG/LEAD for previous/next comparisons; widen the frame for LAST_VALUE
Frames: ROWS counts rows, RANGE measures values (peers together, intervals), GROUPS counts tie-groups
Patterns: Top-N per group, deduplication, running totals, moving averages, gaps-and-islands