SQL Window Functions: Calculating Running Totals and Ranks

#1SQL window functions: the analytics feature that keeps row detail
What we tested: We formatted and ran SQL queries against SQLite (in-browser) and PostgreSQL 16. Query parsing, formatting output, and execution plans were compared across dialects.
Window functions are useful when you want analytics without losing the original row.
They let you calculate ranks, running totals, and moving averages while keeping each record visible, which makes them much easier to use for reporting and debugging than a pile of subqueries.
#21. Window Functions vs. GROUP BY: The Fundamental Difference
Understanding window functions starts with seeing how they differ from standard SQL aggregate functions (SUM, AVG, COUNT, MAX, MIN):
#3Standard GROUP BY (Collapses Rows)
When you execute a query with GROUP BY, the database groups rows matching the group key together and reduces them to a single output row per group:
-- Standard GROUP BY query
SELECT category, SUM(price) AS total_category_sales
FROM products
GROUP BY category;Output:
category | total_category_sales
------------+---------------------
Electronics | 4500.00
Furniture | 1200.00Notice that individual product names, IDs, and prices are lost. You only get the summary row.
#3Window Function OVER() (Preserves Individual Rows)
A window function performs the exact same aggregation, but attaches the calculated result as a new column on every individual row:
-- Window function query
SELECT
name,
category,
price,
SUM(price) OVER(PARTITION BY category) AS total_category_sales
FROM products;Output:
name | category | price | total_category_sales
-------------+-------------+---------+---------------------
Laptop | Electronics | 1200.00 | 4500.00
Smartphone | Electronics | 800.00 | 4500.00
Monitor | Electronics | 500.00 | 4500.00
Desk Chair | Furniture | 300.00 | 1200.00
Standing Desk| Furniture | 900.00 | 1200.00Every row remains intact. You can compare an individual product's price (1200.00) directly against its category total (4500.00) within the same query without a JOIN or subquery.
#22. Anatomy of a Window Function: The OVER() Clause
The syntax for any SQL window function follows a standard pattern:
FUNCTION() OVER (
[PARTITION BY partition_column]
[ORDER BY sort_column [ASC|DESC]]
[ROWS|RANGE frame_specification]
)#3Component 1: PARTITION BY (Optional)
PARTITION BY divides the query result set into subsets or "windows" of rows. The window function is evaluated independently within each partition. If omitted, the entire result set is treated as a single window.
#3Component 2: ORDER BY (Optional)
ORDER BY defines the logical ordering of rows within each partition. For functions like running totals (SUM), ranking (ROW_NUMBER), or row navigation (LAG), the ordering is essential to determine the calculation sequence.
#3Component 3: Window Frame (ROWS / RANGE)
The frame specification defines a dynamic subset of rows within the partition to include in the calculation relative to the current row.
#23. Calculating Running Totals and Cumulative Aggregates
Calculating a running total (cumulative sum) is one of the most common analytical tasks.
#3Running Total Query
SELECT
sale_date,
amount,
SUM(amount) OVER (
ORDER BY sale_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM sales;Output:
sale_date | amount | running_total
-----------+--------+--------------
2025-01-01 | 100.00 | 100.00
2025-01-02 | 150.00 | 250.00
2025-01-03 | 200.00 | 450.00
2025-01-04 | 50.00 | 500.00When ORDER BY is included inside OVER(), SQL defaults the frame specification to ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. This means for each row, SUM() calculates the sum of all rows from the beginning of the partition up to the current row.
#3Partitioned Running Total (e.g., Per Customer)
To reset the running total for each customer, add PARTITION BY:
SELECT
customer_id,
sale_date,
amount,
SUM(amount) OVER (
PARTITION BY customer_id
ORDER BY sale_date
) AS customer_running_total
FROM sales;#24. Ranking Functions: ROW_NUMBER, RANK, and DENSE_RANK
SQL provides three distinct functions for ranking rows within a partition. Understanding how they handle ties is critical:
| Function | Sequence for Ties (Tied at 2nd Place) | Skips Numbers? | Use Case |
|---|---|---|---|
ROW_NUMBER() | 1, 2, 3, 4 | No | Unique row pagination, deduping |
RANK() | 1, 2, 2, 4 | Yes | Standard competition ranking |
DENSE_RANK() | 1, 2, 2, 3 | No | Continuous rank tiers |
#3Demonstration Query
SELECT
student_name,
score,
ROW_NUMBER() OVER (ORDER BY score DESC) AS row_num,
RANK() OVER (ORDER BY score DESC) AS rnk,
DENSE_RANK() OVER (ORDER BY score DESC) AS dense_rnk
FROM exam_results;Output:
student_name | score | row_num | rnk | dense_rnk
-------------+-------+---------+-----+----------
Alice | 95 | 1 | 1 | 1
Bob | 90 | 2 | 2 | 2
Charlie | 90 | 3 | 2 | 2
David | 85 | 4 | 4 | 3 <-- Notice: RANK skipped 3; DENSE_RANK did not
Eve | 80 | 5 | 5 | 4#3Real-World Pattern: Top N Per Group using a CTE
Suppose you want to find the top 2 highest-paid employees in each department. Because window functions cannot be placed directly in a WHERE clause, wrap the window query in a Common Table Expression (CTE):
WITH RankedEmployees AS (
SELECT
employee_id,
name,
department_id,
salary,
DENSE_RANK() OVER (
PARTITION BY department_id
ORDER BY salary DESC
) AS salary_rank
FROM employees
)
SELECT department_id, name, salary, salary_rank
FROM RankedEmployees
WHERE salary_rank <= 2;#25. Navigating Adjacent Rows: LAG and LEAD
LAG() accesses data from a previous row in the partition, while LEAD() accesses data from a subsequent row, without self-joins.
#3Calculating Month-over-Month Growth Rate
SELECT
month,
revenue,
LAG(revenue, 1) OVER (ORDER BY month) AS prev_month_revenue,
revenue - LAG(revenue, 1) OVER (ORDER BY month) AS absolute_growth,
ROUND(
(revenue - LAG(revenue, 1) OVER (ORDER BY month)) /
LAG(revenue, 1) OVER (ORDER BY month) * 100, 2
) AS percentage_growth
FROM monthly_financials;Output:
month | revenue | prev_month_revenue | absolute_growth | percentage_growth
--------+----------+--------------------+-----------------+------------------
2025-01 | 50000.00 | NULL | NULL | NULL
2025-02 | 62000.00 | 50000.00 | 12000.00 | 24.00%
2025-03 | 58000.00 | 62000.00 | -4000.00 | -6.45%LAG(column, offset, default_value) allows setting a default value instead of NULL when accessing preceding rows outside the window bounds.
#26. Advanced Window Frames: Moving Averages
By explicitly defining ROWS BETWEEN, you can build moving averages (smooth time series trends):
#37-Day Moving Average Query
SELECT
record_date,
daily_active_users,
AVG(daily_active_users) OVER (
ORDER BY record_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS seven_day_moving_avg
FROM daily_metrics;This calculates the average of the current row and the 6 preceding rows (total 7 rows), creating a smooth 7-day rolling trend.
#27. Performance and Indexing Best Practices
While window functions are significantly faster than correlated subqueries, improperly structured queries on large tables (millions of rows) can consume high memory.
#31. Match Indexes to PARTITION BY and ORDER BY
For optimal performance, create composite database indexes that cover the PARTITION BY and ORDER BY columns in exact sequence:
-- Index optimization for: OVER (PARTITION BY department_id ORDER BY salary DESC)
CREATE INDEX idx_emp_dept_salary ON employees(department_id, salary DESC);When an index matches the window definition, the database engine can evaluate the window function directly from the index scan without allocating memory for an explicit sort step.
#32. Filter Rows BEFORE the Window Step
Window functions execute after WHERE, GROUP BY, and HAVING clauses in SQL execution order. Ensure your WHERE clause filters out unnecessary historical data before the database builds the window frame:
SQL Logical Execution Order:
1. FROM / JOIN
2. WHERE <-- Filters data FIRST
3. GROUP BY
4. HAVING
5. WINDOW / OVER() <-- Evaluates window functions
6. SELECT
7. DISTINCT
8. ORDER BY
9. LIMIT / OFFSET#3Filtering Rows inside Window Frames (EXCLUDE Clause)
Modern SQL (PostgreSQL 12+, SQLite 3.28+) supports the EXCLUDE sub-clause inside window frame specifications:
EXCLUDE CURRENT ROW: Excludes the current row from the frame calculation.EXCLUDE GROUP: Excludes the current row and any tied rows in the ordering.EXCLUDE TIES: Retains the current row but excludes any tied peer rows.EXCLUDE NO OTHERS: Default behavior (excludes nothing).
-- Calculate average price of OTHER products in the same category (excluding self)
SELECT
name,
category,
price,
AVG(price) OVER (
PARTITION BY category
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
EXCLUDE CURRENT ROW
) AS avg_other_products_price
FROM products;#3Window Function Execution Order vs. Aggregate Functions
A common source of confusion is how window functions interact with GROUP BY when both appear in the same query.
-- Query with BOTH GROUP BY and Window Functions
SELECT
department_id,
SUM(salary) AS total_dept_salary,
RANK() OVER (ORDER BY SUM(salary) DESC) AS dept_salary_rank
FROM employees
GROUP BY department_id;How SQL processes this query:
- First,
GROUP BY department_idcollapses raw employee rows into department summary rows and computesSUM(salary). - Next, the window function
RANK() OVER (ORDER BY SUM(salary) DESC)runs on top of the aggregated department summary rows.
This allows you to rank aggregated groups directly without needing an extra subquery!
#28. Named Windows: Simplifying Complex Queries with WINDOW Clause
When a query contains multiple window functions that share the exact same PARTITION BY and ORDER BY specification, repeating the OVER (...) definition leads to verbose, hard-to-maintain SQL.
The SQL standard provides the WINDOW clause to define a named, reusable window specification at the bottom of the query:
-- Verbose query repeating OVER clause:
SELECT
employee_id,
department_id,
salary,
AVG(salary) OVER (PARTITION BY department_id ORDER BY salary DESC) AS dept_avg,
MAX(salary) OVER (PARTITION BY department_id ORDER BY salary DESC) AS dept_max,
MIN(salary) OVER (PARTITION BY department_id ORDER BY salary DESC) AS dept_min,
RANK() OVER (PARTITION BY department_id ORDER BY salary DESC) AS salary_rank
FROM employees;
-- Clean query using the named WINDOW clause:
SELECT
employee_id,
department_id,
salary,
AVG(salary) OVER w AS dept_avg,
MAX(salary) OVER w AS dept_max,
MIN(salary) OVER w AS dept_min,
RANK() OVER w AS salary_rank
FROM employees
WINDOW w AS (PARTITION BY department_id ORDER BY salary DESC);Using the WINDOW clause improves query readability, reduces syntax errors, and simplifies query maintenance when updating analytical partition rules.
#29. Debugging Window Queries Safely
When reviewing or writing complex analytical SQL queries, unformatted SQL with nested CTEs and multiple OVER() clauses becomes hard to read.
Use the AllDevToolsHub SQL Formatter:
- Beautify Query Structure: Cleans up nested
OVER(),PARTITION BY, and CTE logic. - Dialect Conversion: Convert queries between PostgreSQL, MySQL, SQL Server, and Oracle.
- 100% Local Execution: Debug queries containing schema details without sending database structures to remote servers.
#2Summary
SQL Window Functions represent one of the most powerful capabilities in modern database analytics:
SUM() OVER(PARTITION BY ... ORDER BY ...): Running totals and cumulative sumsROW_NUMBER()/DENSE_RANK(): Deduplication and top-N ranking per categoryLAG()/LEAD(): Period-over-period growth and time series comparisonROWS BETWEEN 6 PRECEDING AND CURRENT ROW: Moving averages and rolling trends
Debug and format your analytical queries at the AllDevToolsHub SQL Suite.
#2Related Tools
- SQL Formatter, Prettify and format complex window queries locally
- Postgres to MySQL Converter, Fix dialect differences in SQL window functions
#2Related Articles
- Debugging Hallucinated SQL
- SQL vs. NoSQL in 2025: Why the Best Database Is Both
- PostgreSQL JSONB Patterns Guide
#2Frequently Asked Questions
Q: Why can't I use a window function in a WHERE clause?
A: In SQL execution order, the WHERE clause is evaluated before window functions are computed. To filter by a window function result (e.g., WHERE rank <= 3), calculate the window function inside a CTE or subquery first, then filter in the outer query.
Q: What is the difference between ROWS and RANGE in window frames?
A: ROWS counts physical rows relative to the current row regardless of duplicate values in the sort column. RANGE evaluates logical value offsets, if multiple rows have duplicate values in the ORDER BY column, RANGE treats them as a single tied unit. For 99% of running total queries, ROWS is preferred for explicit performance.
Written by Rahul Jalavadiya, founder of AllDevToolsHub. All tools run locally in your browser.
#2Sources / Further reading
- PostgreSQL - Window Functions
- SQL Standard - ISO/IEC 9075-2:2023
- MDN Web Docs - SQL reference
Quick Summary
Window functions are a powerful SQL feature that allows you to perform calculations across a set of rows related to the current row. Unlike `GROUP BY`, window functions don't collapse your result set, making them perfect for running totals, moving averages, and ranking.
Key Takeaways
- `OVER` defines the 'window' of rows for the calculation.
- `PARTITION BY` groups rows within the window (like `GROUP BY` but for sub-sections).
- `ORDER BY` inside the window determines the sequence for running calculations.
- Common functions include `ROW_NUMBER()`, `RANK()`, `SUM()`, and `AVG()`.
When to use it
- Calculating a running total of sales by month.
- Finding the top 3 products in every category.
- Comparing a current row's value to the previous row (using `LAG`).
- Calculating a 7-day moving average of website traffic.
Common Mistakes
- Using `RANK()` when you wanted `DENSE_RANK()` (handling ties differently).
- Forgetting the `ORDER BY` in a running total (resulting in a static sum).
- Using window functions in the `WHERE` clause (they must go in `SELECT` or `ORDER BY`).
- Confusing `PARTITION BY` with `GROUP BY`.
SQL Window Functions: Calculating Running Totals and Ranks, Frequently Asked
What is the difference between `RANK()` and `ROW_NUMBER()`?
`ROW_NUMBER()` always gives a unique number to every row. `RANK()` gives the same number to rows with identical values (ties), then skips the next numbers in the sequence.
Do all databases support window functions?
Most modern RDBMS (Postgres, MySQL 8+, SQL Server, Oracle, SQLite 3.25+) support them. If you're on an older version of MySQL, you might be out of luck.
Can I use `SUM()` as a window function?
Yes! `SUM(amount) OVER (ORDER BY date)` will give you a running total without collapsing the rows.
Tools Mentioned in This Article
SQL Formatter
Beautify and format SQL queries for multiple dialects.
DB Connection String Builder
Visually build connection strings for major databases.
ERD Generator from SQL
Generate Entity-Relationship Diagrams directly from SQL DDL statements.
In-Browser SQL Runner
Execute SQL queries against an in-memory SQLite database via WASM.
Tools, tactics, and toughened-up tips, once a week
New tools, deep-dives on developer workflows, and the occasional gem we found this week. No spam, no tracking. Unsubscribe anytime.
Found an error or have feedback?
We correct errors quickly and document changes in our changelog. Report issues at support@alldevtoolshub.com.