SQL Window Functions: Running Totals, Rankings, and Deduplication

intro

Window functions are one of the most powerful tools in the modern SQL world, yet many developers default to GROUP BY for every aggregation problem. This article covers some of the SQL window patterns that solve the majority of real-world problems and each pattern works across PostgreSQL, MySQL 8+, and SQL Server.

Introduction

You have a sales table and you need to rank each rep within their region. GROUP BY collapses rows into groups; window functions compute across a group while leaving every row intact.

The difference is in the output. GROUP BY collapses to one row per rep per region:

rep_idregiontotal
3East5200.00
7East4800.00

A window function returns the same rows with a new column added:

rep_idregiontotalrank_in_region
3East5200.001
7East4800.002

That second result comes from adding ROW_NUMBER() OVER (PARTITION BY region ORDER BY SUM(amount) DESC) to the query. The OVER clause is what makes it a window function rather than an aggregate.

How Do SQL Window Functions Work?

Every window function follows the same pattern:

Copy
        
1 function_name() OVER ( 2 PARTITION BY col1 3 ORDER BY col2 4 )

PARTITION BY divides the result set into groups without collapsing rows. ORDER BY controls the row sequence within each group. The OVER clause is what makes the function a window function; PARTITION BY and ORDER BY inside it are both optional. Leave out PARTITION BY and the function runs across the entire result set. Leave out ORDER BY and the function treats the whole partition as one unordered set, computing the same value for every row in it.

Window functions are available in PostgreSQL 8.4+, MySQL 8.0+, and SQL Server 2012+. The syntax is standard SQL and mostly portable across all three.

One rule to know before writing your first query is that you cannot use a window function in a WHERE or HAVING clause. If you need to filter on a window function result, wrap the query in a CTE or subquery. The deduplication section below shows this pattern.

Running totals

The most common first use is a cumulative sum that resets by group.

Copy
        
1 SELECT 2 sale_id, 3 rep_id, 4 region, 5 amount, 6 SUM(amount) OVER ( 7 PARTITION BY region 8 ORDER BY sale_date 9 ) AS regional_running_total 10 FROM sales;

Sample output:

sale_idrep_idregionamountregional_running_total
13East450.00450.00
47East320.00770.00
83East510.001280.00
21West600.00600.00
52West410.001010.00

The sum accumulates within each region and resets when the partition changes. Removing ORDER BY changes the behavior completely. With SUM(amount) OVER (PARTITION BY region) and no ORDER BY, every row based in the East (region) gets the same value:

sale_idregionamountregional_running_total
1East450.001280.00
4East320.001280.00
8East510.001280.00

That is the region total repeated on every row. Useful for computing each row's share of its group total, but not a running sum.

You can write and iterate on these queries in a SQL editor like DbVisualizer, which lets you sort and filter the result set in place without re-running the query.

Ranking within a group

As far as relational databases are concerned, three functions handle ranking - ROW_NUMBER(), RANK(), and DENSE_RANK():

Copy
        
1 SELECT 2 rep_id, 3 region, 4 SUM(amount) AS total, 5 ROW_NUMBER() OVER (PARTITION BY region ORDER BY SUM(amount) DESC) AS row_num, 6 RANK() OVER (PARTITION BY region ORDER BY SUM(amount) DESC) AS rank_position, 7 DENSE_RANK() OVER (PARTITION BY region ORDER BY SUM(amount) DESC) AS dense_rank_position 8 FROM sales 9 GROUP BY rep_id, region;

The results are as follows:

rep_idregiontotalrow_numrank_positiondense_rank_position
3East5200.00111
7East4800.00222
9East4800.00322
5East4100.00443

Here, ROW_NUMBER() assigns a unique consecutive number to every row, RANK() assigns the same number to tied rows but leaves gaps in the sequence that are allocated for the ties, and DENSE_RANK() provides an uninterrupted, continuous sequence.

You see this here:

  • ROW_NUMBER() assigns a unique number regardless of ties.
  • RANK() skips after a tie: no rank 3 when two rows share rank 2.
  • DENSE_RANK() does not skip: rank 3 follows two tied rows at rank 2.

Use ROW_NUMBER() when you need exactly one winner per group. Use RANK() or DENSE_RANK() when the tied position is meaningful data.

Deduplication

As far as deduplication goes, you will mostly use ROW_NUMBER().

ROW_NUMBER() is the standard approach for removing duplicate rows because you can preview exactly what the delete will remove before running it. If your table has duplicates with the same rep, date, and amount but different sale_id values, start with the SELECT:

Copy
        
1 WITH ranked AS ( 2 SELECT 3 *, 4 ROW_NUMBER() OVER ( 5 PARTITION BY rep_id, sale_date, amount 6 ORDER BY sale_id 7 ) AS rn 8 FROM sales 9 ) 10 SELECT * FROM ranked WHERE rn > 1;

Sample output. These are the rows that would be deleted:

sale_idrep_idsale_dateamountrn
832026-01-15450.002
1232026-01-15450.003
1972026-02-03320.002

PARTITION BY groups rows that should be identical. ORDER BY sale_id keeps the lowest ID (earliest insert). Once the SELECT output looks correct, run the delete:

Copy
        
1 -- PostgreSQL and SQL Server 2 WITH ranked AS ( 3 SELECT 4 sale_id, 5 ROW_NUMBER() OVER ( 6 PARTITION BY rep_id, sale_date, amount 7 ORDER BY sale_id 8 ) AS rn 9 FROM sales 10 ) 11 DELETE FROM sales 12 WHERE sale_id IN (SELECT sale_id FROM ranked WHERE rn > 1);

In MySQL 8+, DELETE does not support CTEs directly, so you would use a join as a subquery instead:

Copy
        
1 -- MySQL 8+ 2 DELETE s 3 FROM sales s 4 JOIN ( 5 SELECT sale_id, 6 ROW_NUMBER() OVER ( 7 PARTITION BY rep_id, sale_date, amount 8 ORDER BY sale_id 9 ) AS rn 10 FROM sales 11 ) ranked ON s.sale_id = ranked.sale_id 12 WHERE ranked.rn > 1;

Both versions work the same way: the window function runs inside a subquery or CTE, and the outer DELETE filters on the result. This is the pattern you use whenever you need to filter on a window function.

Performance Considerations

A window function over a large unindexed table would sort the entire partition: to avoid this, you can put the PARTITION BY columns first in a composite index, then the ORDER BY columns afterwards. For the running total query above, this would look like so:

Copy
        
1 CREATE INDEX idx_sales_region_date ON sales (region, sale_date);

region comes first because it is the partition key. sale_date comes second because it is the sort key within each partition. An index that reverses this order (sale_date, region) will not help the window sort.

You can confirm the index is being used by checking the explain plan. DbVisualizer’s explain plan viewer displays the query plan graphically. Look for a sort node. If it shows a disk sort, the partition is too large for memory and an index is missing or the index order is wrong.

Practical Use Cases

You would use SQL window functions if you deal with:

  • Sales dashboards that need ranking. A leaderboard showing every representative with their position in their region uses ROW_NUMBER() with PARTITION BY region. The dashboard renders every representative; the rank column adds the context without a second query.
  • Time-series cleanup before charting. Sensor or event tables often contain duplicates. The ROW_NUMBER() OVER (PARTITION BY device_id, recorded_at) pattern identifies them in seconds and the same query previews exactly what a DELETE would remove.
  • Finding the most recent row per group. Pick the latest order per customer with ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY ordered_at DESC) = 1 in an outer filter. This replaces the awkward “join the table to itself on MAX(ordered_at)” pattern that every SQL learner writes once and never wants to write again.
  • Detecting gaps in a numeric or date sequence. Compute value - LAG(value) OVER (ORDER BY value) and any row where the result is greater than 1 is the first row after a gap. Useful for finding missing invoice numbers, missing sequence IDs, or skipped days in a calendar.

Frequently Asked Questions

Can I use a window function in a WHERE clause?

No. Window functions are computed after WHERE, so the column doesn't exist yet at filter time. Wrap the query in a CTE or subquery and filter on the result in the outer SELECT. The deduplication example above shows the pattern.

What database management systems support window functions?

PostgreSQL added window functions in the version 8.4 released in 2009, SQL Server added window functions in 2012, and MySQL added window functions starting with MySQL 8.0 (2018). SQLite added window functions with the version 3.25 which was released in 2018. Oracle has had them since the version 8i.

What to learn next

To learn more about functions in relational database management systems, keep yourself up to date by visiting blogs like TheTable by DbVisualizer, reading books on MySQL and other database management systems, as well as attending conferences and workshops on databases and related things. You will get the grasp fairly quickly!

If you don’t want to get started on either of those, grab a free trial of DbVisualizer and get your hands dirty running some SQL window functions.

Dbvis download link img
About the author
TheTable
TheTable

The Table by DbVisualizer is where we gather together to learn about and simplify the complexity of working with database technologies.

The Table Icon
Sign up to receive The Table's roundup
More from the table
Title Author Tags Length Published
title

Best Oracle SQL Developer Alternatives for 2026 (Windows & macOS)

author Leslie S. Gyamfi tags Recommendations SQL clients 6 min 2026-10-12
title

SQL Interview Questions and Answers: Part 3: Tackling Advanced Scenarios

author Lukas Vileikis tags SQL 8 min 2026-10-05
title

How to Delete Duplicate Rows in SQL?

author Lukas Vileikis tags SQL 6 min 2026-09-21
title

SQL vs NoSQL Databases: A Practical Guide to Choosing the Right Database for Your Use Case

author Leslie S. Gyamfi tags Database system NOSQL SQL 13 min 2026-09-14
title

SQL for Data Analytics: 5 Advanced Techniques You Should Know

author Lukas Vileikis tags MySQL SQL 5 min 2026-09-07
title

Understanding and Using the MOD Function in SQL

author Antonello Zanini tags MySQL ORACLE POSTGRESQL SQL SQL SERVER 8 min 2026-08-31
title

Ensuring HIPAA Compliance in a Changing Data Landscape

author Lukas Vileikis tags SQL 5 min 2026-08-24
title

What Is a Composite Key in SQL and When to Use It

author Antonello Zanini tags MySQL ORACLE POSTGRESQL SQL SQL SERVER 8 min 2026-08-17
title

SQL Server Full-Text Search: A Practical Guide

author Antonello Zanini tags Full text search SQL SERVER 11 min 2026-08-10

The content provided on dbvis.com/thetable, including but not limited to code and examples, is intended for educational and informational purposes only. We do not make any warranties or representations of any kind. Read more here.