Dinesh/ Blog
data engineering

Window Functions — the Game-Changer

Rank deterministically, reason about peers and frames, and compute calendar-aware deltas and session boundaries.

17 min readChecking device speech…
Lesson preparation & details

Level: beginner

Data Platform & Orchestration · Lesson 5 of 23 ↗
Loading this browser’s progress…

By the end, you should be able to

  • Choose deterministic ranking and tie policy
  • Distinguish peers, frames and calendar periods
  • Compute safe survivor and session annotations

Bring with you

Sources checked: . A source check does not change the publication or update date.

Editorial review: · What review means

In this article · 19 sections

Runtime contract: This review executed the bounded fixtures with Python’s SQLite 3.45.1. Exact Python/SQL examples run on in-memory SQLite. PostgreSQL 16 frame semantics and PostgreSQL/BigQuery syntax are separately source-reviewed; no server, warehouse or streaming service runs. Each example declares whether its input is an employee, event, order or daily aggregate.

Why this session exists

A department summary cannot show Alice's own salary next to the department average without retaining or reattaching employee detail. A window function attaches a computation to each row at its input stage, rather than collapsing those rows as GROUP BY does. That stage may already be grouped: a later window over department summaries preserves department rows, not original employees.

You will choose ROW_NUMBER/RANK/DENSE_RANK tie policies, wrap top-N queries, use LAG/LEAD for ordered neighbours, distinguish partitions from frames, and compute running totals, moving averages, percent-of-total, deduplication candidates and session boundaries with worked results.

Prerequisites and version context

Read joins and aggregations. PostgreSQL 8.4 added window functions in 2009, not 2008; SQLite added them in 3.25.0 (2018). The executed lab requires SQLite 3.30+ for explicit NULLS LAST in addition to windows. Portable wrappers remain useful: PostgreSQL 16 and SQLite do not have QUALIFY. BigQuery supports QUALIFY; the official MySQL 8.0 SELECT grammar includes WINDOW but no QUALIFY clause. MySQL 8's support for window functions does not imply QUALIFY support. Do not conflate those two features as the original timeline did.

Keep detail, attach context

Think of a student report card showing both the student's score and their class mean. Every student gets a row; the class summary is repeated intentionally.

Keep detail, attach context
Figure text equivalent — labels and relationships
  • Node “Six employees” (square).
  • Node “GROUP BY dept: three summary rows” (square).
  • Node “AVG salary OVER PARTITION BY dept: six annotated rows” (square).
  • “Six employees” leads to “GROUP BY dept: three summary rows”. Line: normal; ends: arrow point.
  • “Six employees” leads to “AVG salary OVER PARTITION BY dept: six annotated rows”. Line: normal; ends: arrow point.

For the six non-null employees Alice/Bob/Carol/Dan/Eve/Frank from the earlier fixture:

SELECT id,name,dept,salary,
       RANK() OVER (PARTITION BY dept ORDER BY salary DESC NULLS LAST) AS rk,
       ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC NULLS LAST,id) AS rn,
       AVG(salary) OVER (PARTITION BY dept) AS dept_avg
FROM employees
ORDER BY dept,salary DESC NULLS LAST,id;

Result: Eng Dan 140000 (rk=1,rn=1), Alice 120000 (2,2), Bob 95000 (3,3), each with mean 118333.333…; HR Carol 80000 (1,1) with mean 80000; Sales Eve 110000 (1,1) and Frank 60000 (2,2), mean 85000. The later complete lab adds Heidi, so its Eng mean is instead 112500. These are deliberately different fixtures, not interchangeable output tables.

PARTITION BY chooses the partition's population; peers are the rows equal on all window ORDER BY expressions. Window ORDER BY defines the calculation's ordering; outer ORDER BY defines display ordering. One does not guarantee the other. Without window ORDER BY, SUM(x) OVER (PARTITION BY g) is a partition total, not a nondeterministic running total.

Ranking and tie policy

For descending scores [100,90,90,80]:

functionresultcontract
ROW_NUMBER1,2,3,4one number per row; tied-row assignment needs a total order to be reproducible
RANK1,2,2,4peer ties share rank; next rank skips by peer-group size
DENSE_RANK1,2,2,3ranks distinct peer groups without gaps
NTILE(3)1,1,2,3 for this four-row total orderdistributes rows into buckets whose sizes differ by at most one; larger buckets first

Add a unique ID to ROW_NUMBER's ordering for a reproducible survivor. Do not add it to a salary RANK if equal salaries should tie: peer equality uses every window ORDER BY expression. DENSE_RANK yields distinct-value bands, not quantiles. NTILE can split a tie across buckets and does not guarantee equal counts when row count is not divisible by k.

PERCENT_RANK is (rank-1)/(partition_rows-1) and is 0 for a singleton. It need not reach 1 if the last rows tie, and pct >= 0.5 does not guarantee exactly half the rows. These are rank-derived positions, not a continuous median/percentile value calculation.

Top-N, filtering and percent-of-total

Windows are evaluated logically after WHERE/grouping/HAVING. Filter their result in an outer query, not in the same WHERE:

WITH ranked AS (
    SELECT id,name,dept,salary,
           ROW_NUMBER() OVER (
               PARTITION BY dept ORDER BY salary DESC NULLS LAST,id
           ) AS rn,
           AVG(salary) OVER (PARTITION BY dept) AS dept_avg
    FROM employees
)
SELECT name,dept,salary,dept_avg FROM ranked
WHERE rn <= 2
ORDER BY dept,rn;

On the seven-person Heidi fixture, this gives Dan and Alice, Carol, Eve and Frank. A three-row-per-customer orders query uses the same shape with PARTITION BY customer_id ORDER BY amount DESC,id and rn <= 3. To retain every tied top salary use RANK on salary only; a maximum-join can also retain ties intentionally. A join-back result with tied timestamps is not inherently a bug—it is wrong only if the contract required one survivor.

Percent-of-customer total preserves each order while attaching the denominator:

SELECT id,customer_id,amount,
       SUM(amount) OVER (PARTITION BY customer_id) AS customer_total,
       1.0*amount/NULLIF(SUM(amount) OVER (PARTITION BY customer_id),0) AS fraction
FROM orders
ORDER BY customer_id,id;

For customer 1's amounts 500 and 300, total=800 on each row and fractions are 0.625 and 0.375. The real-valued numerator avoids integer division; NULLIF declares zero-total fractions undefined. Multiply by 100 only if displaying a percentage. PostgreSQL numeric types can provide exact decimal arithmetic instead of binary float display.

Full top-three answer with a portable NULL policy

This self-contained form runs in SQLite 3.45.1 and uses a CASE ordering expression instead of assuming every engine accepts NULLS LAST. Tied amounts keep both rows when room remains, with IDs deciding which row crosses a three-row cutoff.

WITH orders(id,customer_id,amount) AS (
    VALUES (1,10,100),(2,10,90),(3,10,90),(4,10,80),(5,20,NULL)
), ranked AS (
    SELECT id,customer_id,amount,
           ROW_NUMBER() OVER (PARTITION BY customer_id
             ORDER BY CASE WHEN amount IS NULL THEN 1 ELSE 0 END,amount DESC,id) AS rn
    FROM orders
)
SELECT id,customer_id,amount,rn FROM ranked WHERE rn<=3 ORDER BY customer_id,rn;

Answer: IDs 1,2,3 for customer 10 and ID 5 for customer 20. The NULL row is still eligible and gets rank-number 1 in its singleton partition. If only known amounts are eligible, place WHERE amount IS NOT NULL inside ranked. An outer filter hides NULL rows only after the original population has already been ranked.

Partitions, order and frames

The spotlight model is useful for frame-sensitive functions: partition selects a room of rows, ordering places them, and a frame illuminates the contributing neighbourhood around the current row. Not every window function uses that frame: ROW_NUMBER/RANK/NTILE and LAG/LEAD do not become frame-limited aggregates merely because a frame clause is written.

In PostgreSQL 16 and SQLite, the default ordered aggregate frame is effectively RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, including the current row's peers. With no ORDER BY, the default includes the whole partition. ROWS counts row positions; RANGE uses ordering values/peers. PostgreSQL also has GROUPS for peer-group counts.

Use this deliberately tied fixture:

idtamount
1110
2120
325
SELECT id,
       SUM(amount) OVER (ORDER BY t) AS through_peers,
       SUM(amount) OVER (ORDER BY t,id
           ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS through_row
FROM frame_demo ORDER BY id;

Answer: (1,30,10),(2,30,30),(3,35,35). Peer totals are correct for “through this timestamp”; row totals are correct for an explicitly sequenced transaction history. Neither is intrinsically a bug. Negative adjustments can make either cumulative sum decrease; monotonicity in the lab follows from positive amounts, not from ROWS syntax.

FIRST_VALUE and LAST_VALUE are not the same trap

With values A, B, C ordered by unique positions 1, 2, 3, default FIRST_VALUE returns A,A,A because the frame starts at the partition's first row. Default LAST_VALUE returns A,B,C because its frame ends at the current row (or last peer). The original assertion that FIRST_VALUE defaults to the current row was wrong.

For whole-partition last value:

SELECT id,
       FIRST_VALUE(amount) OVER (ORDER BY t,id) AS first_amount,
       LAST_VALUE(amount) OVER (ORDER BY t,id) AS default_last,
       LAST_VALUE(amount) OVER (ORDER BY t,id
           ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS final_amount
FROM frame_demo ORDER BY id;

Answer: first values 10,10,10; default last 10,20,5; final amounts 5,5,5. A sliding frame can make FIRST_VALUE change, and tied first rows need a deterministic ordering if the selected payload matters.

Moving rows are not moving calendar days

ROWS BETWEEN 6 PRECEDING AND CURRENT ROW means the current row plus up to six preceding rows. It means seven calendar days only after producing one row per day, with a declared missing-day policy. Aggregate multiple orders per day first; generate the calendar if zero-activity days must contribute zero. At the beginning, the frame has fewer than seven rows unless you explicitly require a full window.

The complete lab uses one row per region/day for seven consecutive days. South's amounts are [100,150,300,120,400,350,450]. Its cumulative values are [100,250,550,670,1070,1420,1870]. On January 5 the three-row/day average is (300+120+400)/3 = 273.333…, not 273 unless rounded.

With rows on January 1=100, January 3=300 and January 10=900, the last three-row average is 433.333…, while a two-day preceding RANGE at January 10 includes only 900. A calendar zero-fill would include January 8=0, January 9=0 and January 10=900, yielding 300. These answer different questions.

PostgreSQL, with a timestamp ORDER BY, permits RANGE BETWEEN INTERVAL '2 days' PRECEDING AND CURRENT ROW. SQLite does not support that interval syntax; its numeric RANGE can use ORDER BY julianday(day) RANGE BETWEEN 2 PRECEDING AND CURRENT ROW. A temporal range does not create absent date rows or insert zero values.

LAG, LEAD and sessionisation

LAG/LEAD access an ordered neighbouring row, not automatically yesterday/tomorrow. For January 1 and January 3, LAG at January 3 returns January 1's amount; a join on exactly one-day-prior finds no January 2 row. To call a difference “day-over-day,” use complete daily input or explicitly validate date adjacency. The first LAG/last LEAD normally returns NULL because no such row exists; that differs from a neighbouring row whose payload itself is NULL.

The session rule here is precise: per user, a new session starts at the first event or when the gap from the previous event is greater than 1800 seconds. Exactly 1800 stays in the session. Compute gaps, then flags, then a running flag sum in separate query stages; windows cannot be nested directly at the same stage.

User 1 times 1000,1300,1500,5000,5100 yield session IDs 1,1,1,2,2. User 2 times 2000,2200,8000 yield 1,1,2. An event ID supplies deterministic order for equal timestamps. These IDs are local to the user's ordered dataset; late arrivals can change earlier gap boundaries and IDs. A streaming implementation needs its own watermark/state/retraction policy, not simply this batch query copied into a stream.

Neighbour NULL, missing neighbour and frame scope

LAG's default argument applies when the requested row is absent, not when an existing row has a NULL payload. PostgreSQL 16 and SQLite respect those NULLs; PostgreSQL 16 does not implement the standard IGNORE NULLS option for these functions.

WITH samples(id,x) AS (VALUES (1,10),(2,NULL),(3,30))
SELECT id,LAG(x,1,-1) OVER (ORDER BY id) AS previous,
       LEAD(x,1,-1) OVER (ORDER BY id) AS following,
       LAG(x,1,-1) OVER (ORDER BY id ROWS BETWEEN CURRENT ROW AND CURRENT ROW) AS outside_frame,
       SUM(x) OVER (ORDER BY id ROWS BETWEEN CURRENT ROW AND CURRENT ROW) AS just_this_row
FROM samples ORDER BY id;

Answer: (1,-1,NULL,-1,10), (2,10,30,10,NULL), (3,NULL,-1,NULL,30). The single-row frame limits SUM but not LAG. Use a separate missingness indicator when NULL is an observed unknown; COALESCE would erase the distinction.

For session boundaries, test a user at times 0,1800,3601,3601: the existing lab's strict gap>1800 rule yields IDs 1,1,2,2 under event-ID tie-breaking. Sorting and late-arrival policy belong to the result contract, not just performance tuning.

Deduplication is a survivor policy

ROW_NUMBER() OVER (PARTITION BY event_key ORDER BY inserted_at,id) identifies a deterministic earliest survivor. Preview rows with rn>1 before deleting anything. Deleting all keys with COUNT(*)>1 deletes the keeper too; grouping itself is not forbidden for deduplication if used to select a valid keeper identity.

The lab's (dept,salary) dedup is an intentionally artificial grouping exercise: Bob and Heidi are different people, so this must not be used to clean an employee directory. For actual webhook deduplication, use a stable provider event/business identity and decide whether repeated payloads really denote the same event. A payload hash can conflate legitimate identical events. Selection alone does not prevent a concurrent duplicate insertion; use appropriate uniqueness/idempotency constraints for that invariant.

Hands-on: ranking, frames, neighbours and sessions

Save as windows_lab.py. All intended SQL executes locally; the same-SELECT WHERE/window query is a negative test that must raise, not an ignored failure.

"""windows_lab.py — bounded ranking, frames and session examples."""
from __future__ import annotations
import sqlite3
 
assert sqlite3.sqlite_version_info >= (3,30,0)
conn = sqlite3.connect(":memory:")
cur = conn.cursor()
 
cur.executescript("""
CREATE TABLE employees (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL,
    dept TEXT NOT NULL,
    salary INTEGER,
    hired TEXT
);
INSERT INTO employees VALUES
    (1,'Alice','Eng',   120000,'2020-03-01'),
    (2,'Bob',  'Eng',    95000,'2021-06-15'),
    (3,'Carol','HR',     80000,'2019-01-10'),
    (4,'Dan',  'Eng',   140000,'2018-09-20'),
    (5,'Eve',  'Sales', 110000,'2022-02-01'),
    (6,'Frank','Sales',  60000,'2023-11-05'),
    (7,'Heidi','Eng',    95000,'2024-01-01');   -- ties with Bob
 
CREATE TABLE sales (
    day TEXT NOT NULL,
    region TEXT NOT NULL,
    amount INTEGER NOT NULL,
    PRIMARY KEY(day,region)
);
INSERT INTO sales VALUES
    ('2026-01-01','South',100),('2026-01-01','North',200),
    ('2026-01-02','South',150),('2026-01-02','North',180),
    ('2026-01-03','South',300),('2026-01-03','North',210),
    ('2026-01-04','South',120),('2026-01-04','North',260),
    ('2026-01-05','South',400),('2026-01-05','North',300),
    ('2026-01-06','South',350),('2026-01-06','North',270),
    ('2026-01-07','South',450),('2026-01-07','North',330);
 
CREATE TABLE events (
    user_id INTEGER NOT NULL,
    event_time INTEGER NOT NULL,      -- unix seconds
    event_id INTEGER PRIMARY KEY
);
INSERT INTO events VALUES
    (1, 1000, 1), (1, 1300, 2), (1, 1500, 3),          -- one session
    (1, 5000, 4), (1, 5100, 5),                     -- new session (gap > 30min)
    (2, 2000, 6), (2, 2200, 7), (2, 8000, 8);          -- two sessions
""")
 
def run(label: str, sql: str) -> None:
    print(f"\n-- {label} --")
    for row in cur.execute(sql):
        print(" ", row)
 
# ---- 1. Three windows in one query ----
run("1. Rank, row_number, dept_avg — three window expressions; plan may share work", """
    SELECT name, dept, salary,
           RANK()       OVER (PARTITION BY dept ORDER BY salary DESC) AS rk,
           ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC NULLS LAST, id) AS rn,
           AVG(salary)  OVER (PARTITION BY dept)                        AS dept_avg
    FROM employees
    ORDER BY dept, rk, id;
""")
 
# ---- 2. Top-2 per group — the killer pattern ----
run("2. Top 2 earners per dept (wrap in subquery, filter on rn)", """
    SELECT name, dept, salary FROM (
        SELECT *, ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC NULLS LAST, id) AS rn
        FROM employees
    ) t
    WHERE rn <= 2
    ORDER BY dept, salary DESC;
""")
 
# ---- 3. RANK vs DENSE_RANK on ties (Bob + Heidi tied at 95000) ----
run("3. RANK vs DENSE_RANK on tied salaries", """
    SELECT name, salary,
           RANK()       OVER (ORDER BY salary DESC) AS rk,
           DENSE_RANK() OVER (ORDER BY salary DESC) AS drk
    FROM employees
    ORDER BY salary DESC, name;
""")
 
# ---- 4. Running total + 7-day moving average per region ----
run("4. Running total & 3-day moving average per region", """
    SELECT day, region, amount,
           SUM(amount) OVER (PARTITION BY region ORDER BY day
                             ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running,
           AVG(amount) OVER (PARTITION BY region ORDER BY day
                             ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS ma_3d
    FROM sales
    ORDER BY region, day;
""")
 
# ---- 5. LAG — day-over-day change per region ----
run("5. LAG — day-over-day delta", """
    SELECT day, region, amount,
           LAG(amount) OVER (PARTITION BY region ORDER BY day) AS prev_amt,
           amount - LAG(amount) OVER (PARTITION BY region ORDER BY day) AS delta
    FROM sales
    ORDER BY region, day;
""")
 
# ---- 6. NTILE — quartiles by salary ----
run("6. NTILE(4) — instant quartile buckets", """
    SELECT name, salary, NTILE(4) OVER (ORDER BY salary DESC NULLS LAST, id) AS quartile
    FROM employees
    ORDER BY salary DESC;
""")
 
# ---- 7. FIRST_VALUE — attach the dept's top earner to every row ----
run("7. FIRST_VALUE — top earner column beside every row", """
    SELECT name, dept, salary,
           FIRST_VALUE(name) OVER (PARTITION BY dept ORDER BY salary DESC NULLS LAST, id
                                   ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS top_earner
    FROM employees
    ORDER BY dept, salary DESC;
""")
 
# ---- 8. Sessionisation — 30-min gap rule ----
run("8. Sessionise events by 30-minute gap (1800 sec)", """
    WITH gaps AS (
        SELECT user_id, event_time, event_id,
               event_time - LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time, event_id) AS gap
        FROM events
    ),
    flagged AS (
        SELECT user_id, event_time, event_id,
               CASE WHEN gap IS NULL OR gap > 1800 THEN 1 ELSE 0 END AS is_new_session
        FROM gaps
    )
    SELECT user_id, event_time, event_id,
           SUM(is_new_session) OVER (PARTITION BY user_id ORDER BY event_time, event_id
                                     ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS session_id
    FROM flagged
    ORDER BY user_id, event_time, event_id;
""")
 
# ---- 9. Deduplication — keep the earliest hire per (dept, salary) ----
run("9. Deduplicate: keep earliest row per (dept, salary)", """
    SELECT * FROM (
        SELECT *, ROW_NUMBER() OVER (PARTITION BY dept, salary ORDER BY hired, id) AS rn
        FROM employees
    ) WHERE rn = 1
    ORDER BY dept, salary DESC;
""")
 
# ---- 10. TRAP: filtering on window column in same SELECT ----
try:
    cur.execute("""
        SELECT name, RANK() OVER (ORDER BY salary DESC) AS rk
        FROM employees WHERE rk <= 3;
    """).fetchall()
except sqlite3.OperationalError as e:
    print("\n-- 10. TRAP: filter on window column in same SELECT --")
    print(" Expected error:", e)
else:
    raise AssertionError("Window filter should fail in this SQLite query")
 
conn.close()

Worked output and percentile exercise

The global salary order is Dan 140000, Alice 120000, Eve 110000, Bob 95000, Heidi 95000, Carol 80000, Frank 60000. Therefore global RANK is 1,2,3,4,4,6,7, and DENSE_RANK is 1,2,3,4,4,5,6. The earlier lesson's 3,3,5 was not this query's output. Within Eng alone Bob and Heidi share rank 3.

NTILE(4) over the seven ordered employees has bucket sizes 2,2,2,1 and can split the Bob/Heidi tie. FIRST_VALUE of the top earner repeats Dan within Eng, Carol within HR and Eve within Sales. The synthetic dedup drops Heidi, retaining earlier-hired Bob; six rows remain.

WITH ranked AS (
    SELECT id,name,dept,salary,
           PERCENT_RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS pct
    FROM employees
)
SELECT name,dept,salary,ROUND(pct,2) AS pct_from_top
FROM ranked WHERE pct >= 0.5
ORDER BY dept,pct,id;

Answer: Bob and Heidi in Eng at 0.67, Frank in Sales at 1.0. Carol's singleton HR partition has 0 and is excluded. This is a percentile-rank threshold, not exactly the bottom half of every department.

Plans and production review

Several windows with compatible partition/order requirements may share sort work; identical PARTITION BY alone does not prove one scan or one sort. A matching B-tree on (user_id,event_time,event_id) may supply order, but the planner can choose a scan/sort instead depending on selected rows and payload fetches. Reading all 500 million rows remains substantial even if sorting is avoided.

For latest-per-key choose ROW_NUMBER when you want an explicit survivor/tie policy; a max-and-join retains tied maxima; PostgreSQL DISTINCT ON requires a deliberate ORDER BY; BigQuery QUALIFY filters a window result but is not itself a faster algorithm. Ordered ARRAY_AGG and ordered-set percentiles also differ by dialect. In PostgreSQL 16, ordered-set aggregates such as percentile_cont cannot simply be converted to ordinary window functions using OVER, and sliding distinct-user counts are not a plain SUM of daily counts.

The original vendor sessionisation and timing stories are not verified case studies. The tested mechanism is gap/flag/cumulative-sum on a bounded fixture, not proof that every named analytics product implements exactly this SQL or that self joins are always quadratic.

Recall and explain-aloud answers

  • GROUP BY versus OVER? Collapse into groups versus annotate rows at the current input stage; later filters may still reduce output.
  • ROW_NUMBER versus RANK? One chosen sequence versus shared peer rank; use a tie-breaker only where breaking ties is the intent.
  • Top three per customer? Rank by amount and ID in a CTE, filter rn<=3; use RANK if ties should extend the result.
  • Why no WHERE rn? Window results are not available at that logical stage; use a wrapper or supported QUALIFY dialect.
  • Seven-row frame? Seven days only with one row per calendar day and an explicit boundary/missingness policy.
  • RANGE peers? Amounts 10 and 20 at the same t give 30 for both; explicit ROWS with unique ordering gives 10 then 30.
  • LAG versus yesterday? A missing date makes them different. Validate adjacency or build a daily calendar first.
  • Session algorithm? LAG, gap >1800 flag, cumulative SUM; first row is a new session and equal timestamps need a stable ID.
  • FIRST/LAST? Default FIRST retains the first frame value; default LAST stops at current peers. Whole-partition LAST needs an unbounded following endpoint.
  • Index stretch? (user_id,event_time,event_id) is a candidate for the declared order, not a guaranteed O(n) production speedup. Compare plan and actual work.

Explain-out-loud answer guide

  1. Why keep every row? A window annotates each row at its input stage; grouping instead creates one row per grouping key. Filtering the annotated result later is a separate operation.

  2. ROW_NUMBER or RANK? Use a total-order ROW_NUMBER to choose one reproducible survivor; rank on score alone when equal scores must tie. Adding ID to RANK would destroy those score ties.

  3. Why no same-WHERE filter? WHERE runs before windows at that query level. Compute the annotation in a CTE or derived table, then filter in its outer query; QUALIFY requires a supporting dialect.

Sources and next steps

Continue to CTEs, indexes and query planning. Batch deduplication, temporal analysis and streaming sessions share concepts but not identical runtime contracts.

Primary documentation inspected 10 October 2026: PostgreSQL 16 window functions, SELECT frames, PostgreSQL 8.4 release, SQLite windows and BigQuery QUALIFY. The evidence records exact source passages and separate SQLite oracles; PostgreSQL interval frames and warehouse APIs remain documentation-checked, not runtime-tested.

Optional archived resources

These original resource destinations are retained for continuity, not as independently verified technical evidence. Historical video labels, attribution, durations and playback were not verified in this review; search links are discovery queries, not specific lectures. Repeated links are listed once. Prefer the versioned primary sources above for dialect contracts; the archived current-version documentation may evolve.

Pause / Recall / Apply

Can you explain it without the page?

Close the example. Reconstruct the core idea, then change one assumption. Mark complete when you’re ready; you can always undo it.

Stored in this browser only. No account, no sync. Clearing browser data removes your record.