PL/SQL & T-SQL (Oracle and SQL Server) Module 5 – Window functions in T-SQL
T-SQL ranking and offset window functions
Number rows with ROW_NUMBER, RANK, DENSE_RANK and NTILE, read other rows with LAG and LEAD, and filter window results in a CTE, as T-SQL has no QUALIFY.
What you will learn
- Use ROW_NUMBER, RANK, DENSE_RANK and NTILE with PARTITION BY and ORDER BY
- Use LAG and LEAD with offsets and defaults
- Solve top-N per group and de-duplication without QUALIFY
- State where window functions are allowed in a T-SQL query
Before you start
What you’ll learn · Before you start
What you will learn
- Use ROW_NUMBER, RANK, DENSE_RANK and NTILE with PARTITION BY and ORDER BY
- Use LAG and LEAD with offsets and defaults
- Solve top-N per group and de-duplication without QUALIFY
- State where window functions are allowed in a T-SQL query
Before you start
On this page
Pune sold 20 units each of GHEE-500 and RICE-5K this month, and a top-2 report built on ROW_NUMBER shows only one
of them, picked by nothing more than the order in which the tied rows happened to arrive. T-SQL has four ranking
functions that treat such a tie four different ways, and two offset functions, LAG and LEAD, that read the row
before or after the current one.
You will number rows with the four functions, read other rows with LAG and LEAD, filter on window results in a
CTE, since T-SQL has no QUALIFY, and remove duplicates by keeping row 1 of each key. The sql track’s
window basics cover OVER and PARTITION BY in general; this lesson keeps to
T-SQL’s own rules.
Four ranking functions differ in how they number ties and groups
A ranking function: A window function that numbers the rows of each partition in the order of its OVER clause: ROW_NUMBER, RANK, DENSE_RANK and NTILE in T-SQL, which differ in how they number ties. gives each row a number within its
window partition: The group of rows that PARTITION BY gives a window function to work on; numbering, offsets and frames start again in each partition, and without PARTITION BY the whole result is one partition., the group of rows that PARTITION BY puts it in, in the order
that the ORDER BY of OVER sets, which all four require. Microsoft’s pages define the four. ROW_NUMBER numbers
every row 1, 2, 3, ties or not. RANK gives tied rows the same number and skips the next ones, so a tie at second
place runs 1, 2, 2, 4. DENSE_RANK repeats the number for ties and goes on without a gap: 1, 2, 2, 3. NTILE(n)
cuts the partition into n groups whose sizes differ by one at most, the larger groups first. All four return
bigint. Step through the lab’s two cities:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- One partition per city. PARTITION BY city splits the seven rows into Pune's four and Kochi's three. Every function starts again at 1 in each partition.
- Ordered by units, then product. ORDER BY units DESC puts the best seller first. GHEE-500 and RICE-5K tie in Pune, and RICE-5K and TEA-250 tie in Kochi; the product code breaks the tie for ROW_NUMBER.
- ROW_NUMBER never repeats. ROW_NUMBER numbers every row 1, 2, 3 and so on, ties or not. Without the tie-breaker, which of the tied rows gets the lower number is not guaranteed.
- RANK repeats, then skips. RANK repeats the number for tied rows and then skips ahead by the size of the tie. Pune runs 1, 2, 2, 4, and Kochi 1, 1, 3.
- DENSE_RANK leaves no gap. DENSE_RANK also repeats a number for ties but carries on with the next one. Pune runs 1, 2, 2, 3, and Kochi 1, 1, 2.
- NTILE cuts into groups. NTILE(2) splits each partition into two groups whose sizes differ by one at most, larger groups first. Pune gets two and two, Kochi two and one.
The script ranks the seven rows of the figure:
-- Engine: SQL Server 2025 CU9 (17.0.5005.3); database lab at compatibility level 170. Client: sqlcmd.
-- The four ranking functions on the units of ranking_model.py: two cities, each a partition, with a tie in each.
-- product is the tie-breaker, so ROW_NUMBER gives the same numbers on every run.
SET NOCOUNT ON;
CREATE TABLE #units (city nvarchar(10) NOT NULL, product varchar(12) NOT NULL, units int NOT NULL,
PRIMARY KEY (city, product));
INSERT INTO #units VALUES
(N'Pune', 'TEA-250', 90), (N'Pune', 'RICE-5K', 20), (N'Pune', 'GHEE-500', 20), (N'Pune', 'SALT-1K', 10),
(N'Kochi', 'RICE-5K', 70), (N'Kochi', 'TEA-250', 70), (N'Kochi', 'GHEE-500', 40);
SELECT city, product, units,
ROW_NUMBER() OVER (PARTITION BY city ORDER BY units DESC, product) AS row_number_, -- Pune 1, 2, 3, 4
RANK() OVER (PARTITION BY city ORDER BY units DESC) AS rank_, -- Pune 1, 2, 2, 4
DENSE_RANK() OVER (PARTITION BY city ORDER BY units DESC) AS dense_rank_, -- Pune 1, 2, 2, 3
NTILE(2) OVER (PARTITION BY city ORDER BY units DESC, product) AS half -- Pune 1, 1, 2, 2
FROM #units
ORDER BY city, units DESC, product;
DROP TABLE #units;
GO """ranking_ties.sql in the lab: sqlcmd runs it as sa in the database lab, on a freshly loaded practice schema."""
from mssql_session import Lab
with Lab() as lab:
lab.sqlcmd("ranking/ranking_ties.sql") Output
$ sqlcmd -S localhost -U sa -C -d lab -i ranking/ranking_ties.sql city product units row_number_ rank_ dense_rank_ half ----- -------- ----- ----------- ----- ----------- ---- Kochi RICE-5K 70 1 1 1 1 Kochi TEA-250 70 2 1 1 1 Kochi GHEE-500 40 3 3 2 2 Pune TEA-250 90 1 1 1 1 Pune GHEE-500 20 2 2 2 1 Pune RICE-5K 20 3 2 2 2 Pune SALT-1K 10 4 4 3 2
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ranking_ties_session.py
The model numbers the same rows by the same rules. Press Edit, change RICE-5K’s 20 units in Pune to 90, and run it again:
"""ROW_NUMBER, RANK, DENSE_RANK and NTILE(2) on the same units.
The rules are those of Microsoft's ranking function pages: ROW_NUMBER numbers
every row, RANK gives tied rows the same number and skips the next ones,
DENSE_RANK gives tied rows the same number without gaps, and NTILE(n) cuts a
partition into n groups whose sizes differ by at most one, larger groups
first. Rows are ordered by units, highest first, with the product code as
the tie-breaker. Change RICE-5K's 20 units in Pune to 90 and run it again.
"""
UNITS = [ # (city, product, units sold this month)
("Pune", "TEA-250", 90),
("Pune", "RICE-5K", 20),
("Pune", "GHEE-500", 20),
("Pune", "SALT-1K", 10),
("Kochi", "RICE-5K", 70),
("Kochi", "TEA-250", 70),
("Kochi", "GHEE-500", 40),
]
def ntile(n_rows, groups):
"""Group number of each row: sizes differ by one at most, larger groups first."""
base, extra = divmod(n_rows, groups)
numbers = []
for g in range(groups):
numbers += [g + 1] * (base + (1 if g < extra else 0))
return numbers
print("city product units ROW_NUMBER RANK DENSE_RANK NTILE(2)")
for city in sorted({c for c, _, _ in UNITS}):
rows = sorted((r for r in UNITS if r[0] == city), key=lambda r: (-r[2], r[1]))
tiles = ntile(len(rows), 2)
rank = dense = 0
previous = None
for n, (_, product, units) in enumerate(rows, start=1):
if units != previous:
rank, dense, previous = n, dense + 1, units
print(f"{city:<6} {product:<9} {units:>5} {n:>10} {rank:>4} {dense:>10} {tiles[n - 1]:>8}")
cities = len({c for c, _, _ in UNITS})
summary = f"{len(UNITS)} rows in {cities} partitions; numbering starts again at 1 in each city."
print(summary) Output
city product units ROW_NUMBER RANK DENSE_RANK NTILE(2) Kochi RICE-5K 70 1 1 1 1 Kochi TEA-250 70 2 1 1 1 Kochi GHEE-500 40 3 3 2 2 Pune TEA-250 90 1 1 1 1 Pune GHEE-500 20 2 2 2 1 Pune RICE-5K 20 3 2 2 2 Pune SALT-1K 10 4 4 3 2 7 rows in 2 partitions; numbering starts again at 1 in each city.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ranking_model.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Pune’s tie at 20 units shows the three behaviours: ROW_NUMBER 2 and 3, RANK 2 and 2 and then 4, DENSE_RANK 2 and
2 and then 3. After the edit RICE-5K ties with TEA-250 at the top, and both RANK and DENSE_RANK give them 1.
ROW_NUMBER’s order among tied rows is not guaranteed: Microsoft’s page promises the same numbers on every run only
when the partition and ORDER BY columns together are unique. A tie-breaker: An extra column at the end of an ORDER BY, such as a key, that makes the order unique, so that rows with equal values in the other columns always come in the same order., here
product, added at the end of the ORDER BY, makes them unique.
Checkpoint
Check: Ranking functions and ties
LAG and LEAD read another row of the partition, with a default at the edges
LAG and LEAD are offset function: A window function that reads a value from another row of the partition at a given distance from the current row, such as LAG for an earlier row and LEAD for a later one.: LAG(units, 1, 0) reads units from the
row one place before the current one in the window’s order, and LEAD from the row after it, without a self-join.
Microsoft’s LAG page sets the rules for the two optional arguments. The offset defaults to 1 and may not be
negative. The default is the value returned when the offset points past the edge of the partition, NULL when none is
given, and it must be type-compatible with the first argument. Neither function takes a frame. The script reads each
city’s monthly sales of TEA-250:
-- Engine: SQL Server 2025 CU9 (17.0.5005.3); database lab at compatibility level 170. Client: sqlcmd.
-- LAG and LEAD over each city's monthly units of TEA-250, as lag_model.py computes them. The default 0 of
-- LAG(units, 1, 0) fills the first month of each partition; without it the first month gets NULL.
SET NOCOUNT ON;
CREATE TABLE #monthly (city nvarchar(10) NOT NULL, month_no int NOT NULL, units int NOT NULL,
PRIMARY KEY (city, month_no));
INSERT INTO #monthly VALUES (N'Pune', 1, 60), (N'Pune', 2, 75), (N'Pune', 3, 75), (N'Pune', 4, 90),
(N'Kochi', 1, 50), (N'Kochi', 2, 50), (N'Kochi', 3, 65);
SELECT city, month_no, units,
LAG(units, 1, 0) OVER (PARTITION BY city ORDER BY month_no) AS previous_or_0, -- Pune 0, 60, 75, 75
units - LAG(units) OVER (PARTITION BY city ORDER BY month_no) AS change, -- Pune NULL, 15, 0, 15
LEAD(units) OVER (PARTITION BY city ORDER BY month_no) AS next_month, -- Pune 75, 75, 90, NULL
LAG(units, 2) OVER (PARTITION BY city ORDER BY month_no) AS two_back -- Pune NULL, NULL, 60, 75
FROM #monthly
ORDER BY city DESC, month_no;
DROP TABLE #monthly;
GO """lag_lead.sql in the lab: sqlcmd runs it as sa in the database lab, on a freshly loaded practice schema."""
from mssql_session import Lab
with Lab() as lab:
lab.sqlcmd("offset/lag_lead.sql") Output
$ sqlcmd -S localhost -U sa -C -d lab -i offset/lag_lead.sql city month_no units previous_or_0 change next_month two_back ----- -------- ----- ------------- ------ ---------- -------- Pune 1 60 0 NULL 75 NULL Pune 2 75 60 15 75 NULL Pune 3 75 75 0 90 60 Pune 4 90 75 15 NULL 75 Kochi 1 50 0 NULL 50 NULL Kochi 2 50 50 0 65 NULL Kochi 3 65 50 15 NULL 50
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lag_lead_session.py
The model computes the same columns, except that its change from the month before subtracts LAG(units, 1, 0) where
the script subtracts LAG(units). Press Edit, set DEFAULT = None, and run it again:
"""LAG and LEAD over each city's monthly sales of TEA-250.
LAG(units, offset, default) reads the row offset rows before the current one
in the partition and LEAD the row after it; past the edge of the partition
they return the default, NULL unless one is given. The rules are those of
Microsoft's LAG and LEAD pages. Change the default of LAG from 0 to None and
run it again.
"""
SALES = { # city -> units of TEA-250 in months 1, 2, 3 and 4
"Pune": [60, 75, 75, 90],
"Kochi": [50, 50, 65],
}
DEFAULT = 0 # the third argument of LAG(units, 1, 0)
def lag(values, i, offset=1, default=None):
return values[i - offset] if i - offset >= 0 else default
def lead(values, i, offset=1, default=None):
return values[i + offset] if i + offset < len(values) else default
def show(value):
return "NULL" if value is None else str(value)
print("city month units LAG(1, default) change LEAD LAG(2)")
for city, units in SALES.items():
for i, u in enumerate(units):
before = lag(units, i, 1, DEFAULT)
change = None if before is None else u - before
print(f"{city:<6} {i + 1:>5} {u:>5} {show(before):>15} {show(change):>6} {show(lead(units, i)):>4} {show(lag(units, i, 2)):>6}")
first_rows = len(SALES)
note = f"{first_rows} first months get the default as their previous value."
print(note) Output
city month units LAG(1, default) change LEAD LAG(2) Pune 1 60 0 60 75 NULL Pune 2 75 60 15 75 NULL Pune 3 75 75 0 90 60 Pune 4 90 75 15 NULL 75 Kochi 1 50 0 50 50 NULL Kochi 2 50 50 0 65 NULL Kochi 3 65 50 15 NULL 50 2 first months get the default as their previous value.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lag_model.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Each city’s first month has no month before it, so LAG(units, 1, 0) returns the default 0, and the change column
shows Pune’s first month as a rise of 60 from nothing. After the edit the first month’s previous value and its change
are NULL, which is what a report should show for a month with no history. Each partition starts again: Kochi’s first
month gets the default as well.
A default of 0 inside a growth rate
Wrong: units - LAG(units, 1, 0) OVER (…) or a growth rate divided by LAG(units, 1, 0). The first month shows a
rise from zero, and a division by the default fails with error 8134. Right: leave the default out, so the first
month’s change is NULL, and handle it in the report; a default fits only where 0 is a true value.
SQL Server 2022 adds IGNORE NULLS to LAG and LEAD, which the
frames lesson uses to carry a value forward.
Checkpoint
Check: LAG, LEAD, offsets and defaults
A window function runs after WHERE, so its filter goes in a CTE
Microsoft’s SELECT page lists the order in which SQL Server binds a query: FROM, ON, JOIN, WHERE,
GROUP BY, WITH CUBE or WITH ROLLUP, HAVING, SELECT, DISTINCT, ORDER BY and TOP. Window functions are
computed in the SELECT step, so WHERE runs before they have a value. Error 4108 says so in words: windowed functions can only appear in the
SELECT or ORDER BY clauses. Some engines add a QUALIFY clause to filter on window results; Oracle AI Database
26ai has one. SQL Server does not: Microsoft’s SELECT page lists QUALIFY for Fabric Data Warehouse only. Follow a
query through the clauses:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The rows come first. SQL Server binds FROM, ON and JOIN first, so every later clause can use the tables' columns.
- WHERE runs before any window. WHERE filters single rows before any window function has a value. That is why a ROW_NUMBER() in WHERE fails with error 4108, and the error names the only places allowed.
- Groups and their filter. GROUP BY and HAVING come next. A window function may use the grouped rows, such as RANK() OVER (ORDER BY SUM(units) DESC), because it runs after them.
- Window functions in SELECT. Window functions are computed in the SELECT step, over the rows that are left after WHERE, GROUP BY and HAVING.
- ORDER BY can read them. ORDER BY and TOP come last, so ORDER BY may use a window function or its alias. To filter on one, compute it in a CTE or derived table and filter in the outer query.
Both refusals, and Oracle’s clause, are in the scripts:
SQL (where_errors.sql) · topn/where_errors.sql
-- Engine: SQL Server 2025 CU9 (17.0.5005.3); database lab at compatibility level 170. Client: sqlcmd.
-- Two ways to filter on a window function that T-SQL refuses, each in a batch of its own: in WHERE (error 4108,
-- "Windowed functions can only appear in the SELECT or ORDER BY clauses.") and with QUALIFY, which SQL Server does
-- not have (a syntax error). The CTE in top_n_per_city.sql is the form that works.
SET NOCOUNT ON;
CREATE TABLE #units (city nvarchar(10), product varchar(12), units int);
INSERT INTO #units VALUES (N'Pune', 'TEA-250', 90), (N'Pune', 'RICE-5K', 20), (N'Kochi', 'RICE-5K', 70);
GO
SELECT city, product FROM #units
WHERE ROW_NUMBER() OVER (PARTITION BY city ORDER BY units DESC) = 1; -- error 4108
GO
SELECT city, product FROM #units
QUALIFY ROW_NUMBER() OVER (PARTITION BY city ORDER BY units DESC) = 1; -- syntax error: no QUALIFY clause
GO
DROP TABLE #units;
GO SQL (qualify_oracle.sql) · topn/qualify_oracle.sql
-- Engine: Oracle AI Database 26ai Free, Release 23.26.3.0.0; user LAB in FREEPDB1. Client: SQL*Plus.
-- Oracle AI Database 26ai has a QUALIFY clause, which filters on window functions in the same query block; a port
-- of this query to T-SQL moves the filter into a CTE or a derived table.
WITH units (city, product, units) AS (
SELECT 'Pune', 'TEA-250', 90 FROM dual UNION ALL SELECT 'Pune', 'RICE-5K', 20 FROM dual UNION ALL
SELECT 'Pune', 'GHEE-500', 20 FROM dual UNION ALL SELECT 'Pune', 'SALT-1K', 10 FROM dual UNION ALL
SELECT 'Kochi', 'RICE-5K', 70 FROM dual UNION ALL SELECT 'Kochi', 'TEA-250', 70 FROM dual UNION ALL
SELECT 'Kochi', 'GHEE-500', 40 FROM dual
)
SELECT city, product, units
FROM units
QUALIFY RANK() OVER (PARTITION BY city ORDER BY units DESC) <= 2
ORDER BY city, units DESC, product; SQL Server · where_errors_session.py
"""where_errors.sql in the lab: sqlcmd runs it as sa in the database lab, on a freshly loaded practice schema."""
from mssql_session import Lab
with Lab() as lab:
lab.sqlcmd("topn/where_errors.sql", errors=2) Output
$ sqlcmd -S localhost -U sa -C -d lab -i topn/where_errors.sql Msg 4108, Level 15, State 1, Server lab-mssql, Line 2 Windowed functions can only appear in the SELECT or ORDER BY clauses. Msg 102, Level 15, State 1, Server lab-mssql, Line 2 Incorrect syntax near 'ROW_NUMBER'.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 where_errors_session.py
Oracle · qualify_oracle_session.py
"""qualify_oracle.sql in the lab: SQL*Plus runs it as LAB on a freshly loaded practice schema."""
from oracle_session import Lab
with Lab() as lab:
lab.sqlplus("topn/qualify_oracle.sql") Output
$ sqlplus -s lab@//localhost/FREEPDB1 @topn/qualify_oracle.sql CITY PRODUCT UNITS ----- -------- ---------- Kochi RICE-5K 70 Kochi TEA-250 70 Pune TEA-250 90 Pune GHEE-500 20 Pune RICE-5K 20
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 qualify_oracle_session.py
The T-SQL form computes the number in a CTE and filters in the outer query. That is the shape of every top-N per group: number each city’s products by units, then keep the rows numbered up to N. Which function to use depends on ties; answer three questions about your report:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Function | Tied rows | Rows for a top 2 | Gaps after a tie |
|---|---|---|---|
| ROW_NUMBER Gives Pune: 1, 2, 3, 4 | Poor split by the tie-breaker | Good exactly 2 rows in every city | Good never any gaps |
| RANK Gives Pune: 1, 2, 2, 4 | Good share one number | Mixed 3 rows in Pune, 2 in Kochi | Mixed skips after a tie: 1, 2, 2, 4 |
| DENSE_RANK Gives Pune: 1, 2, 2, 3 | Good share one number | Mixed 3 rows in each city | Good no gaps: 1, 2, 2, 3 |
| NTILE(2) Gives Pune: 1, 1, 2, 2 | Poor may split a tie across groups | Poor groups by size, not a top N | Good group numbers run 1 to n |
How the answers decide
- “Must tied rows share a place?” Yes, ties share a place: rules out ROW_NUMBER. ROW_NUMBER gives every row its own number, so one of two tied products drops out of a top N.
- “Must a top N return exactly N rows?” Yes, exactly N: rules out RANK and DENSE_RANK. Ties add rows to a top N under RANK and DENSE_RANK, as Pune's three rows show.
- “Do you need equal-sized groups rather than places?” No, places: rules out NTILE(2). NTILE makes groups of nearly equal size; it does not number places.
- “Do you need equal-sized groups rather than places?” Yes, equal groups: rules out ROW_NUMBER, RANK and DENSE_RANK. Only NTILE divides a partition into a given number of groups.
- “Must tied rows share a place?” Yes, ties share a place and “Must a top N return exactly N rows?” No, ties may add rows: adds a caveat to RANK. RANK leaves a gap after a tie: Pune's places run 1, 2, 2, 4, so a list of places 1 to 3 has no place 3.
The model runs the top-2 query with each of the three numbering functions. Press Edit, set TOP = 1, and run it
again:
"""Top 2 products per city with three numbering functions.
The query numbers each city's products by units, highest first, in a CTE and
keeps the rows numbered 1 or 2 in the outer query. ROW_NUMBER returns exactly
two rows a city and drops one of two tied products by its tie-breaker; RANK and
DENSE_RANK keep ties, and DENSE_RANK can return more rows still. Set TOP to 1
and run it again.
"""
UNITS = [
("Pune", "TEA-250", 90), ("Pune", "RICE-5K", 20), ("Pune", "GHEE-500", 20), ("Pune", "SALT-1K", 10),
("Kochi", "RICE-5K", 70), ("Kochi", "TEA-250", 70), ("Kochi", "GHEE-500", 40),
]
TOP = 2
def numbered(rows, kind):
out, rank, dense, previous = [], 0, 0, None
for n, (product, units) in enumerate(rows, start=1):
if units != previous:
rank, dense, previous = n, dense + 1, units
out.append((product, units, {"ROW_NUMBER": n, "RANK": rank, "DENSE_RANK": dense}[kind]))
return out
for kind in ("ROW_NUMBER", "RANK", "DENSE_RANK"):
print(f"{kind} <= {TOP}")
for city in sorted({c for c, _, _ in UNITS}):
rows = sorted(((p, u) for c, p, u in UNITS if c == city), key=lambda r: (-r[1], r[0]))
kept = [f"{p} ({u})" for p, u, n in numbered(rows, kind) if n <= TOP]
print(f" {city:<6} {len(kept)} row{'' if len(kept) == 1 else 's'}: {', '.join(kept)}") Output
ROW_NUMBER <= 2 Kochi 2 rows: RICE-5K (70), TEA-250 (70) Pune 2 rows: TEA-250 (90), GHEE-500 (20) RANK <= 2 Kochi 2 rows: RICE-5K (70), TEA-250 (70) Pune 3 rows: TEA-250 (90), GHEE-500 (20), RICE-5K (20) DENSE_RANK <= 2 Kochi 3 rows: RICE-5K (70), TEA-250 (70), GHEE-500 (40) Pune 3 rows: TEA-250 (90), GHEE-500 (20), RICE-5K (20)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 top_n_model.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
ROW_NUMBER returns exactly two rows a city and drops RICE-5K in Pune, RANK keeps Pune’s tie and returns three
rows there, and DENSE_RANK returns three in each city, because Kochi’s third product is only the second distinct
amount. After the edit Kochi’s top two products tie for first, so RANK and DENSE_RANK both return two rows for it.
For a single group, TOP (2) WITH TIES … ORDER BY units DESC does what RANK() <= 2 does:
-- Engine: SQL Server 2025 CU9 (17.0.5005.3); database lab at compatibility level 170. Client: sqlcmd.
-- Top 2 products per city: the window function is computed in a CTE and filtered in the outer query, because a
-- WHERE cannot read it. ROW_NUMBER keeps exactly two rows a city; RANK keeps Pune's tie at second place.
SET NOCOUNT ON;
CREATE TABLE #units (city nvarchar(10) NOT NULL, product varchar(12) NOT NULL, units int NOT NULL,
PRIMARY KEY (city, product));
INSERT INTO #units VALUES
(N'Pune', 'TEA-250', 90), (N'Pune', 'RICE-5K', 20), (N'Pune', 'GHEE-500', 20), (N'Pune', 'SALT-1K', 10),
(N'Kochi', 'RICE-5K', 70), (N'Kochi', 'TEA-250', 70), (N'Kochi', 'GHEE-500', 40);
WITH ranked AS (
SELECT city, product, units,
ROW_NUMBER() OVER (PARTITION BY city ORDER BY units DESC, product) AS n,
RANK() OVER (PARTITION BY city ORDER BY units DESC) AS place
FROM #units
)
SELECT city, product, units, n, place
FROM ranked
WHERE place <= 2 -- Pune: TEA-250, GHEE-500, RICE-5K; Kochi: RICE-5K, TEA-250
ORDER BY city, place, product;
-- One city only: TOP (2) WITH TIES keeps the rows that tie with the last one, as RANK <= 2 does.
SELECT TOP (2) WITH TIES product, units FROM #units WHERE city = N'Pune' ORDER BY units DESC; -- 3 rows
DROP TABLE #units;
GO """top_n_per_city.sql in the lab: sqlcmd runs it as sa in the database lab, on a freshly loaded practice schema."""
from mssql_session import Lab
with Lab() as lab:
lab.sqlcmd("topn/top_n_per_city.sql") Output
$ sqlcmd -S localhost -U sa -C -d lab -i topn/top_n_per_city.sql city product units n place ----- -------- ----- - ----- Kochi RICE-5K 70 1 1 Kochi TEA-250 70 2 1 Pune TEA-250 90 1 1 Pune GHEE-500 20 2 2 Pune RICE-5K 20 3 2 product units -------- ----- TEA-250 90 GHEE-500 20 RICE-5K 20
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 top_n_per_city_session.py
Checkpoint
Check: Filtering on a window result
De-duplicating keeps row 1 of each key and deletes the rest
A feed that delivers the same order twice, or an old status beside the new one, leaves several rows for one key.
Numbering the rows of each key newest first, ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY updated_at DESC, event_id DESC), gives the row to keep the number 1. Microsoft’s CTE page allows a DELETE after a CTE, so the
statement can delete through the CTE, DELETE FROM numbered WHERE n > 1, and OUTPUT deleted.… lists what it
removed:
-- Engine: SQL Server 2025 CU9 (17.0.5005.3); database lab at compatibility level 170. Client: sqlcmd.
-- The feed of dedupe_model.py: six status rows for three orders. ROW_NUMBER in a CTE numbers each order's rows
-- newest first, with event_id as the tie-breaker; the DELETE through the CTE removes every row numbered above 1 and
-- OUTPUT shows what it removed.
SET NOCOUNT ON;
CREATE TABLE #order_status (event_id int PRIMARY KEY, order_id int NOT NULL, status nvarchar(10) NOT NULL,
updated_at datetime2(0) NOT NULL);
INSERT INTO #order_status VALUES
(1, 101, N'new', '2026-03-12 09:00'), (2, 101, N'paid', '2026-03-12 09:05'), (3, 101, N'paid', '2026-03-12 09:05'),
(4, 102, N'new', '2026-03-12 09:01'), (5, 102, N'paid', '2026-03-12 09:30'), (6, 103, N'new', '2026-03-12 09:10');
WITH numbered AS (
SELECT event_id, order_id, status,
ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY updated_at DESC, event_id DESC) AS n
FROM #order_status
)
DELETE FROM numbered
OUTPUT deleted.event_id, deleted.order_id, deleted.status -- events 1, 2 and 4
WHERE n > 1;
SELECT event_id, order_id, status FROM #order_status ORDER BY order_id; -- events 3, 5 and 6
DROP TABLE #order_status;
GO """latest_status_per_order.sql in the lab: sqlcmd runs it as sa in the database lab, on a freshly loaded practice
schema."""
from mssql_session import Lab
with Lab() as lab:
lab.sqlcmd("dedupe/latest_status_per_order.sql") Output
$ sqlcmd -S localhost -U sa -C -d lab -i dedupe/latest_status_per_order.sql
event_id order_id status
-------- -------- ------
2 101 paid
1 101 new
4 102 new
event_id order_id status
-------- -------- ------
3 101 paid
5 102 paid
6 103 new
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 latest_status_per_order_session.py
The model replays the six rows. Press Edit, make order_key return (updated_at,) without event_id, and run it
again:
"""Keeping the newest status of each order and deleting the rest.
A feed delivered six status rows for three orders, one of them twice at the
same minute. ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY updated_at DESC,
event_id DESC) gives each order's newest row the number 1, with event_id as the
tie-breaker for rows of the same minute; a DELETE through the CTE removes every
row numbered above 1. Remove event_id from the ORDER BY tuple and run it again.
"""
EVENTS = [ # (event_id, order_id, status, updated_at)
(1, 101, "new", "09:00"),
(2, 101, "paid", "09:05"),
(3, 101, "paid", "09:05"),
(4, 102, "new", "09:01"),
(5, 102, "paid", "09:30"),
(6, 103, "new", "09:10"),
]
def order_key(event):
event_id, _, _, updated_at = event
return (updated_at, event_id) # sorted descending below: newest first, then the higher id
kept, deleted = [], []
for order_id in sorted({e[1] for e in EVENTS}):
rows = sorted((e for e in EVENTS if e[1] == order_id), key=order_key, reverse=True)
for n, event in enumerate(rows, start=1):
(kept if n == 1 else deleted).append((event[0], order_id, event[2], event[3], n))
for label, rows in (("kept", kept), ("deleted", deleted)):
print(f"{label}: {len(rows)} rows")
for event_id, order_id, status, updated_at, n in rows:
print(f" event {event_id} order {order_id} {status:<4} {updated_at} row number {n}") Output
kept: 3 rows event 3 order 101 paid 09:05 row number 1 event 5 order 102 paid 09:30 row number 1 event 6 order 103 new 09:10 row number 1 deleted: 3 rows event 2 order 101 paid 09:05 row number 2 event 1 order 101 new 09:00 row number 3 event 4 order 102 new 09:01 row number 2
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 dedupe_model.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Order 101 has two paid rows at 09:05, and the tie-breaker keeps event 3. After the edit the two rows tie, and the
model keeps event 2, the one it happened to read first; SQL Server gives no promise either way. The tie-breaker is
what makes a de-duplication repeatable.
Key idea
ROW_NUMBER, NTILE, LAG and LEAD need an ORDER BY that is unique within the partition, or tied rows can
trade places from one run to the next, and every filter on a window result needs a CTE or derived table, because
WHERE runs before the window.
Checkpoint
Check: De-duplicating with ROW_NUMBER
Practise: number, read and filter the lab’s rows
The questions use the lab’s units, months and order feed. The idea you are least sure of comes up first, and every choice names the rule that makes it right or wrong:
Practice that adapts to you
Practise this lesson
13 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): for the units 90, 20, 20 and 10, what do ROW_NUMBER, RANK and DENSE_RANK give?
ROW_NUMBER gives 1, 2, 3 and 4: every row its own number, with the order of the two 20s decided by the tie-breaker,
or by chance when there is none. RANK gives 1, 2, 2 and 4: tied rows share a number, and the next row’s number counts
the rows before it, which leaves a gap. DENSE_RANK gives 1, 2, 2 and 3: ties share a number and the next distinct
value takes the next number. All three need an ORDER BY inside OVER.
Warm-up (fresher to 2–5 years): in which clauses may a T-SQL query use a window function?
Only in SELECT and ORDER BY. SQL Server computes window functions in the SELECT step of a query, after FROM,
WHERE, GROUP BY and HAVING, so a window function in WHERE fails with error 4108. T-SQL has no QUALIFY
clause either; to filter on a window result, compute it in a CTE or derived table and filter in the outer query.
Two longer questions follow, one on the latest row per key at scale and one on a duplicate clean-up that must run safely in production. Note your approach to each, then open it:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A dashboard refreshed every minute needs the latest status of each order from a table of 40 million status events. One version numbers all events with ROW_NUMBER in a CTE and keeps n = 1; another uses CROSS APPLY with SELECT TOP (1) ordered by updated_at DESC. Compare the two and choose, with the index each needs.
-
Mid to staff
A nightly feed has left duplicate customer rows, same email, different load times. A developer wants to run DELETE through a CTE with ROW_NUMBER() OVER (PARTITION BY email ORDER BY loaded_at DESC) WHERE n > 1 on production tonight. What would you check or change before it runs, and how do you make it safe to repeat?
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How do ROW_NUMBER, RANK, DENSE_RANK and NTILE number a tie at second place?
ROW_NUMBER gives 1, 2, 3, ties or not; RANK repeats the number and skips the next, 1, 2, 2, 4; DENSE_RANK repeats it without a gap, 1, 2, 2, 3; NTILE(n) cuts the partition into n groups whose sizes differ by one at most, the larger ones first. All four return bigint and all four require an ORDER BY in OVER.
-
When does ROW_NUMBER promise the same numbers on every run?
Only when the partition and ORDER BY columns together are unique. Otherwise tied rows can trade places between runs, which is why a tie-breaker column goes at the end of the ORDER BY.
-
What are LAG's optional arguments, and what are their rules?
The offset, 1 by default and never negative, and the default value returned when the offset points past the edge of the partition, NULL when none is given and type-compatible with the first argument. Neither LAG nor LEAD takes a frame, and each partition starts again.
-
Why is LAG(units, 1, 0) the wrong thing to subtract in a change column?
The first row of each partition has no row before it, so the default 0 makes it look like a rise from nothing, and a growth rate divided by it fails with error 8134. Leave the default out so the first row's change is NULL and the report handles it; a default fits only where 0 is a true value.
-
Why can a WHERE clause not filter on a window function's result?
SQL Server binds the query FROM, ON, JOIN, WHERE, GROUP BY, WITH CUBE or ROLLUP, HAVING, SELECT, DISTINCT, ORDER BY, TOP, and window functions are computed in the SELECT step, so WHERE runs before they have a value. Error 4108 says windowed functions can only appear in the SELECT or ORDER BY clauses.
-
Does T-SQL have QUALIFY?
No. Microsoft lists QUALIFY for Fabric Data Warehouse only, while Oracle AI Database 26ai has one. In SQL Server every filter on a window result goes through a CTE or a derived table: number the rows inside, filter in the outer query.
-
Which numbering function should a top-2 per group use?
It depends on ties. ROW_NUMBER returns exactly two rows per group and drops one of a tied pair; RANK keeps the tie and returns three; DENSE_RANK counts distinct amounts, so it can return three in every group. For a single group, TOP (2) WITH TIES ... ORDER BY does what RANK() <= 2 does.
-
How do you remove duplicate rows for one key?
Number each key's rows newest first with ROW_NUMBER() OVER (PARTITION BY key ORDER BY updated_at DESC, event_id DESC), then DELETE through the CTE where the number is above 1, with OUTPUT deleted. to list what went. The tie-breaker is what makes the result repeatable.
Key takeaways
ROW_NUMBER,RANK,DENSE_RANKandNTILEdiffer only on ties and groups: 1, 2, 3, 4 against 1, 2, 2, 4 and 1, 2, 2, 3 for a tie at second place, and groups of nearly equal size forNTILE.LAGandLEADread a row before or after the current one within its partition, with an offset of 1 and a default of NULL unless the call says otherwise.- Window functions may appear only in
SELECTandORDER BY, and T-SQL has noQUALIFY, so top-N per group and every other filter on a window result go through a CTE or a derived table. - De-duplication numbers each key’s rows with a unique
ORDER BY, keeps row 1 and deletes the rest through the CTE.
Terms in this lesson
- offset function
- A window function that reads a value from another row of the partition at a given distance from the current row, such as LAG for an earlier row and LEAD for a later one.
- ranking function
- A window function that numbers the rows of each partition in the order of its OVER clause: ROW_NUMBER, RANK, DENSE_RANK and NTILE in T-SQL, which differ in how they number ties.
- tie-breaker
- An extra column at the end of an ORDER BY, such as a key, that makes the order unique, so that rows with equal values in the other columns always come in the same order.
- window partition
- The group of rows that PARTITION BY gives a window function to work on; numbering, offsets and frames start again in each partition, and without PARTITION BY the whole result is one partition.
References
- SELECT - OVER clause (Transact-SQL) (Microsoft)
- Ranking functions (Transact-SQL) (Microsoft)
- ROW_NUMBER (Transact-SQL) (Microsoft)
- RANK (Transact-SQL) (Microsoft)
- DENSE_RANK (Transact-SQL) (Microsoft)
- NTILE (Transact-SQL) (Microsoft)
- LAG (Transact-SQL) (Microsoft)
- LEAD (Transact-SQL) (Microsoft)
- SELECT (Transact-SQL) (Microsoft)
- WITH common_table_expression (Transact-SQL) (Microsoft)
- DELETE (Transact-SQL) (Microsoft)
- TOP (Transact-SQL) (Microsoft)
- Database engine events and errors (4000 to 4999) (Microsoft)
- Database engine events and errors (8000 to 8999) (Microsoft)
- SELECT (Oracle AI Database SQL Language Reference 26ai) (Oracle)
Report a problem with this lesson
Kept only in this browser. Your Learn progress