SQL Advanced: Window Functions, CTEs & Analytics
Window functions and CTEs transform how you think about analytical SQL. Go beyond GROUP BY — compute rankings, running totals, moving averages, period comparisons, and hierarchical data without sacrificing row-level detail.
Window Functions — What and Why
Window functions perform calculations across a set of rows (a "window") while still returning one row per result row. This is the key difference from GROUP BY:
- GROUP BY collapses rows — you lose row-level detail
- Window functions keep every row and add computed values alongside
Real-world use cases:
- Rank products by revenue within each category
- Compare this month's sales to last month's for each store
- Calculate a running total of orders per customer
- Find which employees are above average within their department
- Identify the top-N items per group
OVER() Syntax — PARTITION BY, ORDER BY & Frames
Every window function uses the OVER() clause to define its window.
Components of OVER():
| Column | Type | Description |
|---|---|---|
PARTITION BY | Optional | Divides rows into independent groups. Like GROUP BY but without collapsing rows. |
ORDER BY | Optional | Defines row sequence inside each partition. Required for ranking and running total functions. |
ROWS BETWEEN | Optional frame | Precisely defines the window frame for aggregate window functions. |
Frame Specification
Window functions execute AFTER WHERE, GROUP BY, and HAVING
Window functions are computed last in the SQL execution order — after all filtering. You cannot filter on a window function's result directly in a WHERE clause. Wrap the query in a subquery or CTE and filter on the outer query.
Ranking Functions — ROW_NUMBER, RANK, DENSE_RANK, NTILE
Ranking functions assign a position number to each row within a partition.
| Column | Type | Description |
|---|---|---|
ROW_NUMBER() | Ranking | Assigns unique numbers — ties broken arbitrarily. No gaps. |
RANK() | Ranking | Tied rows get the same rank. Next rank skips (1, 2, 2, 4). |
DENSE_RANK() | Ranking | Tied rows get the same rank. No gaps in sequence (1, 2, 2, 3). |
NTILE(n) | Ranking | Divides rows into n equal buckets. Returns the bucket number (1 to n). |
Top-N Per Group — The Classic Pattern
ROW_NUMBER vs RANK for top-N
Use ROW_NUMBER() when you want exactly N rows per group (deterministic). Use RANK() <= N when you want to include all tied entries at position N (you may get more than N rows if there are ties at the boundary).
Percentile — Where Does This Row Stand?
LAG & LEAD — Comparing Rows Across Periods
LAG looks back at previous rows; LEAD looks ahead at next rows. Both are essential for period-over-period comparisons.
LAG — Access a Previous Row's Value
LEAD — Access a Next Row's Value
Store vs. Previous Store Performance
Running Totals & Moving Averages
Running totals and moving averages are the most common analytical patterns in retail and finance reporting.
Running Total (Cumulative Sum)
Moving Average
Running Minimum and Maximum
RC:OMS uses window functions extensively for store performance dashboards, demand forecasting, and cross-channel revenue attribution. The patterns on this page run daily against 100M+ row datasets.
Explore RC:OMS →Common Table Expressions (CTE)
A CTE is a named temporary result set defined using the WITH clause. It exists only for the duration of a single query, but can be referenced multiple times within that query.
Why CTEs over subqueries:
- Dramatically more readable — complex queries become step-by-step narratives
- Can be referenced multiple times within the same query (unlike inline views)
- Make it easy to debug intermediate steps
- Required for recursive queries
Basic CTE
Multiple CTEs — Chained Logic
CTEs are not materialized by default
In most databases (Oracle, PostgreSQL), a CTE is not automatically materialized (cached). The database optimizer may inline the CTE or execute it multiple times if referenced more than once. If you need guaranteed materialization (run once, result cached), use Oracle's WITH /* + MATERIALIZE */ cte_name AS (...) hint, or a global temporary table.
Recursive CTE — Hierarchical Queries
Recursive CTEs execute the same query repeatedly, each time building on the results of the previous iteration. They are the standard way to traverse hierarchical or graph-structured data in SQL.
Structure of a Recursive CTE
Employee-Manager Hierarchy
Generating a Number Sequence
Generating a Date Calendar
Always set a recursion depth limit
A recursive CTE without a proper termination condition will recurse indefinitely and consume all available memory. In Oracle, you can use CYCLE detection. In PostgreSQL, set a maximum iteration count in the WHERE clause (WHERE depth < 20). Always test with small datasets first.
Common Gotchas
Important Gotchas
- !
Filtering on window function results in WHERE causes an error. Window functions execute after WHERE. You must wrap the query in a subquery or CTE:
WITH ranked AS (SELECT ..., ROW_NUMBER() OVER(...) AS rn FROM ...) SELECT * FROM ranked WHERE rn = 1. - !
RANK() gaps vs DENSE_RANK() no-gaps. When you use
RANK() <= 3, you may get more than 3 rows if there are ties at position 3 — and if position 2 has a tie, position 3 becomes position 4 (skip). Decide consciously whether you want RANK or DENSE_RANK for your use case. - !
Missing ORDER BY in window function gives non-deterministic results. Without ORDER BY inside OVER(), functions like ROW_NUMBER() return results in arbitrary order. For running totals and ranking, ORDER BY inside the OVER() clause is mandatory.
- !
LAG/LEAD returning NULL on the first/last row. The first row has no previous row, so LAG returns NULL by default. Use the optional third argument
LAG(value, 1, 0)to specify a default value instead of NULL. - !
Infinite recursion in recursive CTEs. Always include a termination condition in the recursive member's WHERE clause (e.g.,
WHERE depth < 50). Circular references in hierarchical data — where employee A reports to B who reports to A — will cause infinite recursion without cycle detection. - !
Confusing ROWS BETWEEN and RANGE BETWEEN.
ROWS BETWEEN 2 PRECEDING AND CURRENT ROWlooks at the previous 2 physical rows.RANGE BETWEEN 2 PRECEDING AND CURRENT ROWlooks at rows where the ORDER BY value is within 2 of the current row — very different when there are ties or gaps in values.
Key Takeaways
Key Takeaways
- Window functions compute across related rows while preserving individual row detail — unlike GROUP BY which collapses rows. The OVER() clause defines the window with PARTITION BY, ORDER BY, and an optional frame.
- ROW_NUMBER() assigns unique numbers (no ties). RANK() allows ties with gaps. DENSE_RANK() allows ties without gaps. NTILE(n) divides rows into n equal buckets.
- LAG/LEAD access values from previous/next rows without a self-join — essential for period-over-period comparisons, churn detection, and sequence analysis.
- Running totals use SUM() OVER (ORDER BY col ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW). Moving averages use AVG() OVER (ORDER BY col ROWS BETWEEN n PRECEDING AND CURRENT ROW).
- CTEs (WITH clause) make complex queries readable and maintainable. Multiple CTEs chain together like steps in a pipeline. CTEs can reference themselves recursively for hierarchical data traversal.
- Recursive CTEs need an anchor query (starting point), a recursive query (references the CTE), UNION ALL between them, and a termination condition in the WHERE clause to prevent infinite loops.


