PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 11: Window functions

Top-N per group, DISTINCT ON and gaps and islands

Level 3: Advanced SQL25 min

Three classic problems appear in almost every interview and many production queries: the latest row per key, the top N per group, and runs of consecutive values. PostgreSQL has a shortcut for the first (DISTINCT ON) and window functions solve the rest.

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • Get the latest row per key with DISTINCT ON
  • Get the top N per group with row_number or LATERAL and know which scales
  • Find consecutive runs with the gaps-and-islands technique

conceptThe concept

SELECT DISTINCT ON (customer_id) ... ORDER BY customer_id, created_at DESC keeps the first row of each customer according to the ORDER BY. The DISTINCT ON expressions must lead the ORDER BY. It is PostgreSQL-specific and very concise.

For the top N, number rows with row_number() OVER (PARTITION BY g ORDER BY x DESC) in a subquery and keep rn <= N. This sorts every row. When groups are many and an index on (g, x) exists, LATERAL (... ORDER BY x DESC LIMIT N) per group can be far faster because each probe reads only N index entries.

Gaps and islands: for consecutive values, value - row_number() is constant within a run. Group by that difference and every group is one island. The same trick works for dates: day - row_number()::int.

whyWhy it matters

"Latest status per device", "top 3 products per category", "longest login streak" are everyday requirements. Picking the right pattern is the difference between a 5 ms and a 5 s query at scale.

Use caseReal-world scenario

The home page shows the three best-rated products per category, the CRM shows each customer's latest order, and the loyalty programme rewards customers active on 3 or more consecutive days.

Try itExamples

Run and edit these in the interactive lesson.

Latest order per customer with DISTINCT ON
SELECT DISTINCT ON (o.customer_id)
       o.customer_id, o.id AS order_id, o.created_at::date AS day, o.status
FROM orders o
WHERE o.customer_id <= 5
ORDER BY o.customer_id, o.created_at DESC;
Top 3 per category with row_number
SELECT category, name, avg_rating, reviews
FROM (
  SELECT p.category, p.name,
         round(avg(r.rating), 2) AS avg_rating,
         count(*) AS reviews,
         row_number() OVER (PARTITION BY p.category ORDER BY avg(r.rating) DESC, count(*) DESC, p.id) AS rn
  FROM products p
  JOIN reviews r ON r.product_id = p.id
  GROUP BY p.id
  HAVING count(*) >= 10
) ranked
WHERE rn <= 3
ORDER BY category, rn;
Top 2 per group with LATERAL
SELECT c.category, top.name, top.price
FROM (SELECT DISTINCT category FROM products) c
CROSS JOIN LATERAL (
  SELECT name, price FROM products p
  WHERE p.category = c.category
  ORDER BY price DESC
  LIMIT 2
) top
ORDER BY c.category, top.price DESC;
Gaps and islands: consecutive active days
WITH days AS (
  SELECT DISTINCT customer_id, (at AT TIME ZONE 'UTC')::date AS day
  FROM events
  WHERE customer_id BETWEEN 1 AND 300
), grouped AS (
  SELECT customer_id, day,
         day - (row_number() OVER (PARTITION BY customer_id ORDER BY day))::int AS island
  FROM days
)
SELECT customer_id, min(day) AS streak_start, max(day) AS streak_end, count(*) AS days
FROM grouped
GROUP BY customer_id, island
HAVING count(*) >= 3
ORDER BY days DESC, customer_id
LIMIT 10;

Avoid / preferDo this, not that

Avoid
SELECT * FROM orders o
WHERE created_at = (SELECT max(created_at) FROM orders WHERE customer_id = o.customer_id);
Prefer
SELECT DISTINCT ON (customer_id) *
FROM orders
ORDER BY customer_id, created_at DESC, id DESC;

The max() version returns several rows per customer on ties and runs a subquery per row. DISTINCT ON with a tie-breaker returns exactly one.

Avoid
row_number() OVER (PARTITION BY category ORDER BY price)
Prefer
row_number() OVER (PARTITION BY category ORDER BY price, id)

Without a unique tie-breaker, products with equal prices get numbers in arbitrary order, so "top 3" can change between runs.

PitfallsCommon mistakes

  • Writing DISTINCT ON without an ORDER BY that decides which row wins. The result is then arbitrary.
  • Using rank() for "exactly N per group" when ties exist: you get more than N rows.
  • Running the row_number pattern over a huge table to fetch the top 3 for a handful of groups. Filter first, or use LATERAL with a supporting index.

LimitsWhen not to use it

DISTINCT ON is PostgreSQL-specific. For SQL that must run on other databases, use row_number() in a subquery.

SecuritySecurity notes

In multi-tenant tables, filter by tenant before ranking. Ranking across tenants and filtering afterwards can leak how many rows other tenants have through rank numbers.

ChallengeTry it yourself

Return the cheapest active product in every category, breaking price ties by id.

Show one solution
One solution
SELECT DISTINCT ON (category) category, name, price
FROM products
WHERE active
ORDER BY category, price, id;

InterviewQuestions you should be able to answer

How do you get the latest row per key in PostgreSQL?
Three options: DISTINCT ON (key) ... ORDER BY key, ts DESC (concise, PostgreSQL-specific); row_number() OVER (PARTITION BY key ORDER BY ts DESC) = 1 in a subquery (portable); or a LATERAL subquery with ORDER BY ts DESC LIMIT 1 per key, which is fastest when there are few keys relative to rows and an index on (key, ts DESC) exists. A plain GROUP BY with max(ts) only gives the timestamp, not the rest of the row.
Explain the gaps-and-islands technique.
Within a run of consecutive values, both the value and its row_number increase by one per row, so their difference is constant. Computing value - row_number() OVER (PARTITION BY k ORDER BY value) gives an island id; grouping by (k, island id) yields one row per run with min, max and count. With dates, subtract the row number as an integer number of days.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation