Readineer
PATTERN 15SQL Patterns / Analytical & Sequence

SQL window functions and ranking: ROW_NUMBER, RANK, and DENSE_RANK

Learn how window functions calculate across related rows without collapsing them, how OVER and PARTITION BY define the comparison set, and when to use ROW_NUMBER, RANK, or DENSE_RANK.

8 min read10 sections2 predictions
01Foundation

Calculating across rows without losing them

In the aggregation article, we learned how GROUP BY summarizes several rows into one result row. Consider the following orders table, where the value of each order is quantity * unit_price:

Orders
order_idcustomerproductquantityunit_pricestatus
1001AshaWireless Mouse2750paid
1002BenMechanical Keyboard13200pending
1003CarlaLaptop Sleeve31200paid
1004DevOffice Chair18500cancelled
1005AshaWireless Mouse1750paid
1006BenMonitor Stand21800paid

Suppose we want the total order value for each customer. We can use GROUP BY:

Query
SELECT
    customer,
    SUM(quantity * unit_price) AS total_order_value
FROM orders
GROUP BY customer
ORDER BY customer;
Result
customertotal_order_value
Asha2250
Ben6800
Carla3600
Dev8500

The six original order rows have become four customer rows. That is exactly what aggregation is supposed to do. But now consider a different question: show every individual order, but also rank the orders from highest to lowest value. We still need every order row. We do not want six rows collapsed into one summary; we want six rows to remain six rows, with an additional calculated rank value. This is what window functions are designed for.

GROUP BY vs window functions

GROUP BY changes the grain of the result, producing one row per customer, and the individual order rows disappear into the summary. A window function keeps the individual rows:

Query
SELECT
    order_id,
    customer,
    quantity * unit_price AS order_amount,
    ROW_NUMBER() OVER (
        ORDER BY quantity * unit_price DESC
    ) AS position
FROM orders;
Result
order_idcustomerorder_amountposition
1004Dev85001
1003Carla36002
1006Ben36003
1002Ben32004
1001Asha15005
1005Asha7506

Every order still exists. The window function simply adds information calculated by looking at other rows.

Key idea
GROUP BY combines rows. A window function looks across rows while keeping them separate.
02The window

What does OVER mean?

In ROW_NUMBER() OVER (ORDER BY quantity * unit_price DESC), ROW_NUMBER() is the window function and OVER (...) tells SQL to calculate this function across a set of related rows. That set of rows is called the window. Here it uses all rows in the query result and orders them from highest order amount to lowest, and then ROW_NUMBER() gives each row a position:

Orders  --order by amount descending-->   8500   --ROW_NUMBER-->   1
                                          3600                    2
                                          3600                    3
                                          3200                    4
                                          1500                    5
                                           750                    6

The OVER clause is what turns ROW_NUMBER() into a calculation across rows.

ORDER BY inside OVER

The ORDER BY inside a window definition determines how the window function should evaluate the rows. ORDER BY quantity * unit_price DESC means number rows from the largest order amount to the smallest; changing it to ASC reverses the logic. The ordering is part of the analytical calculation.

Window ORDER BY and final ORDER BY are different

These two are not the same clause. Consider a query that ranks by amount inside OVER but displays by order_id:

Query
SELECT
    order_id,
    customer,
    quantity * unit_price AS order_amount,
    ROW_NUMBER() OVER (
        ORDER BY quantity * unit_price DESC
    ) AS amount_position
FROM orders
ORDER BY order_id;
Result
order_idcustomerorder_amountamount_position
1001Asha15005
1002Ben32004
1003Carla36002
1004Dev85001
1005Asha7506
1006Ben36003
ORDER BY inside OVER    controls the window calculation
Final ORDER BY          controls how result rows are displayed
03Ranking functions

ROW_NUMBER, RANK, and DENSE_RANK

ROW_NUMBER() assigns consecutive numbers, and every row receives a different number. Notice the tie between Carla and Ben at 3600: ROW_NUMBER() still gives them different numbers (2 and 3), because it must assign one row number to each row.

Gotcha — ROW_NUMBER does not preserve ties
If two rows have the same ordering value, ROW_NUMBER() does not give them the same position. If the requirement is “assign a unique sequence to every row,” that is correct. But if the requirement is “equal order amounts should receive the same rank,” then ROW_NUMBER() is the wrong function, and RANK() or DENSE_RANK() become useful.

There is another issue with the two 3600 rows: ROW_NUMBER() OVER (ORDER BY quantity * unit_price DESC) does not specify which one becomes 2 and which becomes 3. If the assignment must be predictable, add a tie-breaker:

Query
ROW_NUMBER() OVER (
    ORDER BY
        quantity * unit_price DESC,
        order_id ASC
)

Now 3600 | order 1003 comes before 3600 | order 1006 because 1003 < 1006. This is the same deterministic ordering principle we learned in the Top-N article.

RANK: give tied rows the same rank

If the requirement is “rank orders by value, and orders with the same value should have the same rank,” use RANK():

RANK() OVER (ORDER BY order_amount DESC)
order_idcustomerorder_amountamount_rank
1004Dev85001
1003Carla36002
1006Ben36002
1002Ben32004
1001Asha15005
1005Asha7506

There is no rank 3. Two rows occupied rank 2, so the next row receives rank 4. You can think of the positions as 1, 2, 2, 4, 5, 6. This is the defining behaviour of RANK().

DENSE_RANK: rank ties without gaps

DENSE_RANK() also gives equal values the same rank, but it does not leave gaps afterward:

DENSE_RANK() OVER (ORDER BY order_amount DESC)
order_idcustomerorder_amountamount_rank
1004Dev85001
1003Carla36002
1006Ben36002
1002Ben32003
1001Asha15004
1005Asha7505

The ranking becomes 1, 2, 2, 3, 4, 5 instead of 1, 2, 2, 4, 5, 6.

ROW_NUMBER vs RANK vs DENSE_RANK

Using the same values, the three functions produce:

Side by side
order_amountROW_NUMBERRANKDENSE_RANK
8500111
3600222
3600322
3200443
1500554
750665
ROW_NUMBER   1, 2, 3, 4, 5, 6   every row a unique number
RANK         1, 2, 2, 4, 5, 6   ties share a rank, gaps remain
DENSE_RANK   1, 2, 2, 3, 4, 5   ties share a rank, no gaps
04The featured failure

Using RANK when you really mean the top distinct values

Suppose the requirement is return orders belonging to the top three distinct order amounts. The distinct amounts are 8500, 3600, 3200, 1500, 750, so the top three are 8500, 3600, 3200. Someone computes RANK() OVER (ORDER BY quantity * unit_price DESC), getting 8500 -> 1, 3600 -> 2, 3600 -> 2, 3200 -> 4, and then keeps rank <= 3.

PAUSE & PREDICTDoes the 3200 order remain?
Query
-- Option A: Yes, 3200 is the third-highest distinct amount
-- Option B: No, RANK() assigns it rank 4
-- Option C: SQL automatically changes its rank to 3
-- Option D: The query fails because ranks contain duplicates
Your prediction
05Grouping the calculation

PARTITION BY: restart the calculation for each group

So far, every order has been compared with every other order. But analytical questions often say “rank orders within each status” or “number orders separately for each customer.” This is what PARTITION BY does. To rank orders by amount within each status:

Query
SELECT
    order_id,
    customer,
    status,
    quantity * unit_price AS order_amount,
    RANK() OVER (
        PARTITION BY status
        ORDER BY quantity * unit_price DESC
    ) AS rank_within_status
FROM orders
ORDER BY
    status,
    rank_within_status,
    order_id;
Result
order_idcustomerstatusorder_amountrank_within_status
1004Devcancelled85001
1003Carlapaid36001
1006Benpaid36001
1001Ashapaid15003
1005Ashapaid7504
1002Benpending32001

The ranking restarts for each status. Think of PARTITION BY status as temporarily dividing the rows into independent sets: the paid partition ranks 1, 1, 3, 4, the pending partition 1, and the cancelled partition 1. Then SQL places the calculated values back beside the original rows. The rows are not collapsed, which is a crucial difference from GROUP BY status.

PARTITION BY is not GROUP BY

GROUP BY status with COUNT(*) produces one row per status (paid = 4, pending = 1, cancelled = 1). ROW_NUMBER() OVER (PARTITION BY status ORDER BY order_id) keeps all six orders; PARTITION BY only controls which rows the window function considers together.

GROUP BY        Put rows into groups and collapse each group.
PARTITION BY    Put rows into groups for the calculation, but keep every row.

Ranking within each customer

Another common question: number each customer’s orders from highest value to lowest.

Query
SELECT
    order_id,
    customer,
    quantity * unit_price AS order_amount,
    ROW_NUMBER() OVER (
        PARTITION BY customer
        ORDER BY
            quantity * unit_price DESC,
            order_id ASC
    ) AS customer_order_number
FROM orders
ORDER BY
    customer,
    customer_order_number;
Result
order_idcustomerorder_amountcustomer_order_number
1001Asha15001
1005Asha7502
1006Ben36001
1002Ben32002
1003Carla36001
1004Dev85001

Numbering starts again for each customer. This pattern is extremely useful for problems such as the latest order per customer, the most expensive product per category, the top three transactions per account, the first event per user, or the best-performing item per region.

06Top-N per group

Return the top N rows within each group

One of the most useful ranking patterns is returning the top N rows within each group. To return each customer’s highest-value order, first assign row numbers, then keep row number 1:

Query
WITH ranked_orders AS (
    SELECT
        order_id,
        customer,
        quantity * unit_price AS order_amount,
        ROW_NUMBER() OVER (
            PARTITION BY customer
            ORDER BY
                quantity * unit_price DESC,
                order_id ASC
        ) AS rn
    FROM orders
)
SELECT
    order_id,
    customer,
    order_amount
FROM ranked_orders
WHERE rn = 1
ORDER BY customer;
Result
order_idcustomerorder_amount
1001Asha1500
1006Ben3600
1003Carla3600
1004Dev8500

The logic has two stages: stage 1 ranks orders within each customer, and stage 2 keeps row number 1. Window functions often work naturally with an intermediate query.

Why not put ROW_NUMBER directly in WHERE?

A tempting query filters WHERE ROW_NUMBER() OVER (...) = 1 directly. In many SQL databases, this is not allowed: the WHERE filtering step occurs before window-function results are available for that query level. So the common pattern is to calculate the window function in a subquery or CTE, then filter the calculated result:

Calculate the window function
        v
Subquery or CTE
        v
Filter the calculated result

Some database systems provide additional syntax such as QUALIFY for filtering window results directly, but it is not universal.

07Recognition cues

How to spot window-function problems

Think about window functions when the requirement contains phrases such as:

Rank rows by value.
Number rows within each customer.
Top three orders per customer.
Highest transaction per account.
Latest record per user.
First event within each session.
Compare one row with other related rows.
Calculate something per group while keeping individual rows.

Watch for these mistakes:

  • GROUP BY is used even though individual rows must remain.
  • ROW_NUMBER is used when ties should share a rank.
  • RANK is used when the requirement means top N distinct values.
  • ROW_NUMBER has ties but no deterministic tie-breaker.
  • PARTITION BY uses the wrong grouping column.
  • ORDER BY inside OVER is confused with the final ORDER BY.
  • A window-function alias is filtered directly in WHERE at the same query level.
08A practical mental model

Break a ranking window into three questions

Consider ROW_NUMBER() OVER (PARTITION BY customer ORDER BY quantity * unit_price DESC, order_id ASC):

1 · Which rows belong together?

PARTITION BY customer: orders belonging to the same customer.

2 · How are rows inside each partition ordered?

ORDER BY amount DESC, order_id ASC: higher-value orders first; on ties, smaller order ID first.

3 · What does the function do with that order?

ROW_NUMBER(): assign each row a unique sequence number.

Key idea
Putting it together: within each customer, order purchases from highest to lowest value and give every order a unique number. This translation is often easier to understand than reading the syntax all at once.
09Check your understanding

One more prediction

Consider these order amounts. Requirement: assign equal amounts the same rank, and the next distinct amount should receive the next consecutive rank. Which function should you use?

order amounts
order_idorder_amount
10048500
10033600
10063600
10023200
10011500
1005750
ONE MORE PREDICTIONWhich function should you use?
Query
-- Option A
ROW_NUMBER()

-- Option B
RANK()

-- Option C
DENSE_RANK()

-- Option D
COUNT()
Your prediction
10Summary

Summary

Window functions let SQL calculate across related rows without collapsing those rows, which is the central difference from aggregation: GROUP BY turns many rows into fewer summary rows, while a window function keeps the same rows and adds analytical values. The OVER clause defines the window, and PARTITION BY creates independent groups inside that calculation. The ranking functions differ mainly in how they handle ties: ROW_NUMBER() gives every row a unique number (1, 2, 3, 4); RANK() lets ties share a rank and creates gaps (1, 2, 2, 4); DENSE_RANK() lets ties share a rank without gaps (1, 2, 2, 3).

Key idea
The most important question is not which ranking function is most common, but what should happen when two rows have the same ordering value. If every row must have a unique position, use ROW_NUMBER. If tied rows should share a competition-style position, use RANK. If tied rows should share a rank and the next distinct value should receive the next rank, use DENSE_RANK. And remember: PARTITION BY decides which rows are compared together, ORDER BY decides their analytical order, and the window function decides what value is calculated from that ordering.

Next, we will build on this foundation with running totals, moving averages, and window frames.

Go deeper

Ask Colearn about this pattern

Not sure whether you need ROW_NUMBER, RANK, or DENSE_RANK, or why a window alias will not filter in WHERE? Ask about the concept, or paste a simplified version of your query.

Ask Colearn

3 of 3 free questions left

Instant answers, grounded in the same verified material the diagnostic grades against.

ROW_NUMBER, RANK, or DENSE_RANK: which do I use for ties?

ROW_NUMBER gives every row a unique number even on ties. RANK gives tied rows the same rank but then skips numbers. DENSE_RANK gives tied rows the same rank with no gap afterward.

Ask what should happen when two rows share the ordering value: unique position (ROW_NUMBER), competition-style shared position with gaps (RANK), or shared position without gaps (DENSE_RANK).

from the unit →
Why does RANK() skip a number after a tie?

When two rows tie at rank 2, they occupy positions 2 and 3 conceptually, so the next distinct value receives rank 4. That gap is the defining behaviour of RANK.

If you want the next distinct value to get the next consecutive rank instead (1, 2, 2, 3), use DENSE_RANK.

from the unit →
What is the difference between PARTITION BY and GROUP BY?

GROUP BY puts rows into groups and collapses each group into one summary row. PARTITION BY puts rows into groups only for the calculation, then keeps every original row.

Use PARTITION BY when you need a per-group calculation but still want each individual row in the result.

from the unit →
Why can't I filter ROW_NUMBER() directly in WHERE?

WHERE runs before window-function results exist at that query level, so filtering a window alias there is not allowed in many databases.

Compute the window function in a subquery or CTE, then filter the result: WITH ranked AS (... ROW_NUMBER() OVER (...) AS rn ...) SELECT ... WHERE rn = 1. Some systems offer QUALIFY, but it is not universal.

from the unit →

That’s the free taste

Two ways to go deeper.

Don’t paste credentials, personal data, or confidential production records.

Related patterns

P16Pattern · 11 minRunning totals & moving averagesBuild on OVER and PARTITION BY with window frames for cumulative and rolling calculations.P04Pattern · 8 minAggregation & GROUP BYThe collapsing grain that window functions deliberately avoid.P03Pattern · 8 minOrdering & Top-NThe deterministic tie-breaker principle that makes ROW_NUMBER predictable.
Up next · Pattern 16
Running totals, moving averages, and window frames

Next, we build on this foundation with running totals, moving averages, and window frames.

Continue to P16 →Browse all patterns