When I first learned SQL, GROUP BY felt like all I needed. Then I hit a question it couldn’t answer: “Show me every trip, and next to it, that rider’s total spend.”
GROUP BY collapses rows. I wanted to keep every row and still see an aggregate beside it. That’s what window functions are for.
In this post we’ll use a small taxi company database (a safari schema with trips and drivers tables) to walk through the basics.
What is a window function?
A window function performs a calculation across a set of rows related to the current row without collapsing them into one.
The general shape is:
function_name(...) OVER (
PARTITION BY column_a
ORDER BY column_b
)
Enter fullscreen mode Exit fullscreen mode
-
OVER()is what makes it a window function. -
PARTITION BYsplits rows into groups (windows). It’s likeGROUP BY, but rows aren’t merged. -
ORDER BYsets the order of rows inside each window, which matters for ranking and for “previous/next row” logic.
The simplest example is a bare counter:
SELECT trip_id, fare,
ROW_NUMBER() OVER () AS fare_position
FROM safari.trips;
Enter fullscreen mode Exit fullscreen mode
Every row gets 1, 2, 3, 4… and nothing is collapsed.
The three ranking functions
These three look similar but behave differently when there are ties.
SELECT
trip_id,
rider_rating,
ROW_NUMBER() OVER (ORDER BY rider_rating DESC) AS row_num,
RANK() OVER (ORDER BY rider_rating DESC) AS rank_num,
DENSE_RANK() OVER (ORDER BY rider_rating DESC) AS dense_rank_num
FROM safari.trips
WHERE driver_id = 2
ORDER BY rider_rating DESC;
Enter fullscreen mode Exit fullscreen mode
Function Behavior on ties Example outputROW_NUMBER()
Never repeats; always counts up by 1
1, 2, 3, 4
RANK()
Tied rows share a rank, then it skips numbers
1, 2, 2, 4
DENSE_RANK()
Tied rows share a rank, no gaps
1, 2, 2, 3
When to use which:
-
ROW_NUMBER()when you need a unique position for every row (for example, picking exactly one row per group). -
RANK()for leaderboards, where skipping a position after a tie feels natural (two silver medals, no bronze). -
DENSE_RANK()when you want consecutive rank numbers regardless of ties.
Note that with ROW_NUMBER(), tied rows get an arbitrary order unless you add a tiebreaker column to ORDER BY.
Example 1: Rank all drivers by revenue
Rank all drivers by total revenue, highest first.
Window functions run after GROUP BY. So I aggregate first in a CTE, then rank the result.
WITH driver_revenue AS (
SELECT driver_id, SUM(fare) AS total_revenue
FROM safari.trips
GROUP BY driver_id
)
SELECT
dr.driver_id,
d.driver_name,
dr.total_revenue,
RANK() OVER (ORDER BY dr.total_revenue DESC) AS revenue_rank
FROM driver_revenue dr
JOIN safari.drivers d ON d.driver_id = dr.driver_id;
Enter fullscreen mode Exit fullscreen mode
No PARTITION BY here, because we want one ranking across everyone.
Example 2: Aggregates without losing rows
Show every trip with the rider’s total spend alongside it.
A plain GROUP BY would give one row per rider. With a window, every trip stays:
SELECT
rider_id,
trip_id,
fare,
SUM(fare) OVER (PARTITION BY rider_id) AS rider_total_spend
FROM safari.trips;
Enter fullscreen mode Exit fullscreen mode
Each row now shows its own fare and the rider’s total. From here it’s easy to calculate things like “what percentage of this rider’s spend was this trip?” (fare / SUM(fare) OVER (...)).
Example 3: Comparing to the previous row with LAG
For driver 8, show each trip’s fare, the previous trip’s fare, and the change.
LAG() looks backward a row within the window. (LEAD() looks forward.)
SELECT
t.driver_id,
d.driver_name,
t.trip_date,
t.fare,
LAG(t.fare) OVER (PARTITION BY t.driver_id ORDER BY t.trip_date) AS previous_fare,
t.fare - LAG(t.fare) OVER (PARTITION BY t.driver_id ORDER BY t.trip_date) AS fare_change
FROM safari.trips t
JOIN safari.drivers d ON d.driver_id = t.driver_id
WHERE t.driver_id = 8
ORDER BY t.trip_date;
Enter fullscreen mode Exit fullscreen mode
What happens on the first trip? There’s no previous row, so LAG() returns NULL, and fare - NULL is also NULL as there’s no change to report.
Example 4: Top row per group
For every driver, find their single highest-fare trip.
This is a very common problem, and probably the most useful pattern among these examples. Number the rows within each driver, then keep only number 1.
WITH ranked_trips AS (
SELECT
driver_id,
trip_id,
fare,
trip_date,
ROW_NUMBER() OVER (PARTITION BY driver_id ORDER BY fare DESC) AS fare_rank
FROM safari.trips
)
SELECT
d.driver_name,
t.trip_id,
t.fare,
t.trip_date
FROM ranked_trips t
JOIN safari.drivers d ON d.driver_id = t.driver_id
WHERE t.fare_rank = 1
ORDER BY t.fare DESC;
Enter fullscreen mode Exit fullscreen mode
Why the CTE? You can’t filter on a window function in WHERE, because WHERE is evaluated before window functions run. Wrapping it in a CTE (or subquery) lets you filter on the computed column afterward.
Also note that we use ROW_NUMBER(). If a driver has two trips tied for the highest fare, you get exactly one. Swap in RANK() and you’d get both. Pick based on what the question means.
Summary
-
OVER()turns a function into a window function. -
PARTITION BYdefines the groups;ORDER BYdefines the order within them. - Window functions keep your rows;
GROUP BYcollapses them. -
ROW_NUMBER= unique,RANK= ties with gaps,DENSE_RANK= ties without gaps. -
LAG/LEADcompare a row to its neighbors; the edge rows getNULL. - To filter on a window result, wrap the query in a CTE or subquery.
Once these click, a lot of problems that used to need self-joins or messy subqueries become a few readable lines.