Window functions
OVER and PARTITION BY: aggregates that keep the rows
The idea in 30 seconds
GROUP BY collapses rows into one per group. A window function computes over a set of related rows but keeps every row, so each line can show its own value next to its group's total, its share of it, or a running total. OVER defines the set; PARTITION BY splits it into groups; ORDER BY inside OVER makes it cumulative.
Helps to know first: Aggregation and grouping, Common table expressions (CTEs)
On this page
Group totals without losing the rows
Each item in an order, with the order’s total and its share of it. With GROUP BY you would get the total but lose the items. A window function keeps both:
SELECT oi.order_id, p.name,
oi.quantity * oi.unit_price AS line_total,
sum(oi.quantity * oi.unit_price) OVER (PARTITION BY oi.order_id) AS order_total,
round(100 * oi.quantity * oi.unit_price
/ sum(oi.quantity * oi.unit_price) OVER (PARTITION BY oi.order_id), 1) AS pct_of_order
FROM order_items oi JOIN products p ON p.id = oi.product_id
WHERE oi.order_id IN (101, 103)
ORDER BY oi.order_id, line_total DESC;
order_id | name | line_total | order_total | pct_of_order
----------+---------------------+------------+-------------+--------------
101 | Mechanical keyboard | 4500.00 | 5700.00 | 78.9
101 | Wireless mouse | 1200.00 | 5700.00 | 21.1
103 | USB-C hub | 2499.00 | 5498.00 | 45.5
103 | Laptop stand | 1899.00 | 5498.00 | 34.5
103 | Wireless mouse | 1100.00 | 5498.00 | 20.0
OVER (...)turnssumfrom an aggregate into a window function: it is computed over a set of rows, the window, but each input row stays an output row.PARTITION BY oi.order_idmakes each row’s window the rows of the same order.OVER ()with nothing inside would make the window the whole result.
Running totals: add ORDER BY
ORDER BY inside OVER changes the window from “the whole partition” to “from the start up to this row”. A running total of revenue over time:
WITH totals AS (
SELECT o.id, o.ordered_at, sum(oi.quantity * oi.unit_price) AS order_total
FROM orders o JOIN order_items oi ON oi.order_id = o.id
WHERE o.status <> 'cancelled'
GROUP BY o.id, o.ordered_at
)
SELECT id, ordered_at::date AS day, order_total,
sum(order_total) OVER (ORDER BY ordered_at) AS running_total
FROM totals ORDER BY ordered_at;
id | day | order_total | running_total
-----+------------+-------------+---------------
101 | 2025-03-02 | 5700.00 | 5700.00
102 | 2025-03-05 | 37998.00 | 43698.00
103 | 2025-04-11 | 5498.00 | 49196.00
…
112 | 2025-09-09 | 14500.00 | 143986.00
The last running total, 143,986, is the same total revenue the inflated-report investigation reconciled against.
Where window functions run
Window functions are computed after WHERE, GROUP BY and HAVING, just before the final ORDER BY. Two consequences:
- They see only the rows that survived filtering and grouping. The CTE above grouped items into orders first, then the window added up orders.
- You cannot filter on them in
WHERE:
ERROR: window functions are not allowed in WHERE
To keep, say, only rank 1, compute the window function in a subquery or CTE and filter outside it. The practice page does exactly that.
Check yourself
What is the difference between
sum(x)withGROUP BYandsum(x) OVER (PARTITION BY ...)?Show answer
GROUP BYreturns one row per group. The window version keeps every input row and attaches its group’s sum to each.What does adding
ORDER BYinsideOVERchange?Show answer
The window becomes cumulative: by default it covers rows from the start of the partition up to the current row (and rows tied with it), which gives running totals.
Why can’t you write
WHERE rank() OVER (...) = 1?Show answer
Window functions are computed after
WHERE. Compute the rank in a subquery or CTE, then filter on it in the outer query.
Finished reading?
Saved in this browser only.
Sources
Where each claim on this page comes from.
- 1.PostgreSQL documentation: 3.5 Window Functions (tutorial)Primary· §3.5 Window Functions
A window function calculates across rows related to the current row without grouping them into one output row; it sees the rows left after WHERE, GROUP BY and HAVING; with ORDER BY the default frame runs from the partition start through the current row and its peers; without ORDER BY it is the whole partition.
- 2.PostgreSQL documentation: 4.2 Value ExpressionsPrimary· §4.2.8 Window Function Calls
The default frame is RANGE UNBOUNDED PRECEDING, from the start of the partition through the current row's last peer.
Go further
What to read next, and why it is worth the time.
- 1.PostgreSQL documentation: 3.5 Window Functions (tutorial)Primary· §3.5
The tutorial's employee-salary examples are the clearest introduction to OVER there is.
Applies to PostgreSQL 18. Draft: not yet fact-checked end to end.