A window function computes an aggregate or ranking *over a set of rows related to the current row*, without collapsing them the way `GROUP BY` does. The `OVER` clause defines the window; `PARTITION BY` splits rows into independent groups, and `ORDER BY` orders rows within each partition.
Scanned 9/10/2026
Install to Claude Code
npx -y skills add snoodleboot-io/prompticorn --skill verbose --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Verbose?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/snoodleboot-io-verbose-e37b6568)More formats (shields.io, HTML) on the badges page.
# SQL Window Functions And CTEs (Verbose)
## Core Patterns
### Window Functions Keep Every Row
A window function computes an aggregate or ranking *over a set of rows related to
the current row*, without collapsing them the way `GROUP BY` does. The `OVER`
clause defines the window; `PARTITION BY` splits rows into independent groups, and
`ORDER BY` orders rows within each partition.
```sql
SELECT
region,
salesperson,
amount,
SUM(amount) OVER (PARTITION BY region) AS region_total,
amount * 100.0 / SUM(amount) OVER (PARTITION BY region) AS pct_of_region
FROM sales;
```
Every detail row is preserved, with the region total attached — impossible with a
plain `GROUP BY` without joining the aggregate back.
### Ranking Functions
```sql
SELECT
category, product, revenue,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY revenue DESC) AS rn,
RANK() OVER (PARTITION BY category ORDER BY revenue DESC) AS rnk,
DENSE_RANK() OVER (PARTITION BY category ORDER BY revenue DESC) AS drnk
FROM products;
```
- `ROW_NUMBER` — always unique (1, 2, 3, 4), arbitrary among ties.
- `RANK` — ties share a rank, then a gap (1, 1, 3).
- `DENSE_RANK` — ties share a rank, no gap (1, 1, 2).
Use `ROW_NUMBER` to deduplicate ("keep the newest row per key"); use `RANK` /
`DENSE_RANK` when ties should genuinely tie.
### Top-N Per Group
The canonical use: number rows within each partition, then filter in an outer
query. You cannot filter on a window function in `WHERE` — it is computed after
`WHERE`, so wrap it in a CTE or subquery.
```sql
WITH ranked AS (
SELECT
department, employee, salary,
ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rn
FROM employees
)
SELECT department, employee, salary
FROM ranked
WHERE rn <= 3; -- top 3 earners per department
```
### LAG And LEAD
`LAG` reads a column from a previous row in the ordered partition; `LEAD` reads
from a following row. They replace awkward self-joins for period-over-period math.
```sql
SELECT
product_id,
month,
revenue,
LAG(revenue) OVER (PARTITION BY product_id ORDER BY month) AS prev_month,
revenue - LAG(revenue) OVER (PARTITION BY product_id ORDER BY month) AS mom_delta
FROM monthly_revenue;
```
Provide a default for the boundary row: `LAG(revenue, 1, 0)` returns 0 instead of
NULL for the first month.
### Frames: Running Totals And Moving Averages
Within an ordered partition, a frame restricts which rows the aggregate sees. When
you specify `ORDER BY` without an explicit frame, the default is `RANGE BETWEEN
UNBOUNDED PRECEDING AND CURRENT ROW` — often what you want for a running total, but
be explicit to avoid surprises.
```sql
SELECT
day, amount,
SUM(amount) OVER w AS running_total,
AVG(amount) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)
AS avg_7day
FROM ledger
WINDOW w AS (ORDER BY day ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW);
```
`ROWS` counts physical rows; `RANGE` groups peers with equal `ORDER BY` values.
The distinction matters when the ordering column has duplicates. A named `WINDOW`
clause avoids repeating the same specification across several columns.
### CTEs: Naming The Steps
A `WITH` clause (Common Table Expression) names a subquery so a complex query reads
top-to-bottom instead of nesting inside-out. Chain several to build a pipeline.
```sql
WITH paid AS (
SELECT * FROM invoices WHERE status = 'paid'
),
by_customer AS (
SELECT customer_id, SUM(total) AS revenue
FROM paid
GROUP BY customer_id
)
SELECT c.name, b.revenue
FROM by_customer b
JOIN customers c ON c.id = b.customer_id
ORDER BY b.revenue DESC;
```
CTEs are primarily about readability. On some engines they were once an
optimization fence; on modern PostgreSQL non-recursive CTEs are inlined, so treat
them as a structuring tool rather than a performance lever.
### Recursive CTEs
A recursive CTE has an *anchor* member and a *recursive* member joined by
`UNION ALL`; the recursive part references the CTE itself and runs until it
produces no new rows. It walks hierarchies and generates sequences.
```sql
WITH RECURSIVE subordinates AS (
SELECT id, name, manager_id, 1 AS level
FROM employees
WHERE id = 42 -- anchor: the starting manager
UNION ALL
SELECT e.id, e.name, e.manager_id, s.level + 1
FROM employees e
JOIN subordinates s ON e.manager_id = s.id -- recurse down the tree
)
SELECT * FROM subordinates ORDER BY level;
```
Always ensure the recursion terminates — a cycle in the data (or a missing
stopping condition) produces unbounded rows. Guard with a depth cap or cycle
detection where the graph may contain loops.
### When A Window Beats A Self-Join
"Compare each row to its group's aggregate" and "find the top row per group"
traditionally used correlated subqueries or self-joins that re-scan the table once
per group. A window function does it in a single ordered pass — fewer scans,
clearer intent.
```sql
-- ❌ self-join: scans orders again for every customer
SELECT o.*
FROM orders o
JOIN (
SELECT customer_id, MAX(created_at) AS latest
FROM orders GROUP BY customer_id
) m ON m.customer_id = o.customer_id AND m.latest = o.created_at;
-- ✅ window: one pass
SELECT * FROM (
SELECT o.*,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at DESC) AS rn
FROM orders o
) t
WHERE rn = 1;
```
## Common Anti-Patterns
❌ **GROUP BY then join back to recover detail**
```sql
SELECT o.*, t.dept_avg
FROM orders o
JOIN (SELECT dept, AVG(amount) AS dept_avg FROM orders GROUP BY dept) t
ON t.dept = o.dept;
```
✅ **Attach the aggregate with a window**
```sql
SELECT o.*, AVG(amount) OVER (PARTITION BY dept) AS dept_avg FROM orders o;
```
❌ **Filtering on a window function in WHERE**
```sql
SELECT *, ROW_NUMBER() OVER (ORDER BY score DESC) AS rn
FROM players WHERE rn <= 10; -- error: rn not available in WHERE
```
✅ **Wrap in a CTE or subquery, filter outside**
```sql
WITH r AS (SELECT *, ROW_NUMBER() OVER (ORDER BY score DESC) AS rn FROM players)
SELECT * FROM r WHERE rn <= 10;
```
❌ **ROW_NUMBER when ties should tie**
```sql
ROW_NUMBER() OVER (ORDER BY score DESC) -- two equal scores get 1 and 2
```
✅ **RANK / DENSE_RANK for shared standings**
```sql
RANK() OVER (ORDER BY score DESC)
```
❌ **Recursive CTE with no termination on cyclic data** — grows without bound.
✅ **Cap depth or detect cycles** (`WHERE level < 100`, or track visited ids).
## Window Functions And CTEs Checklist
- [ ] Window used (not GROUP BY + join back) when detail rows must survive
- [ ] Correct ranking function chosen: ROW_NUMBER unique, RANK/DENSE_RANK for ties
- [ ] Window functions filtered in an outer query/CTE, never in WHERE
- [ ] Frame made explicit (`ROWS` / `RANGE`) for running totals and moving averages
- [ ] LAG/LEAD (with a default) used for row-to-row deltas instead of self-joins
- [ ] CTEs used to structure multi-step queries readably
- [ ] Recursive CTEs have a guaranteed termination / cycle guard
- [ ] Execution tuning (indexes, EXPLAIN, N+1) deferred to the sql-optimization skill
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!