Home Database PostgreSQL psql Top N: 5 Proven Per-Group Queries Fast
Intermediate 3 min · September 07, 2026
PostgreSQL psql Introspection and Top N per Group

PostgreSQL psql Top N: 5 Proven Per-Group Queries Fast

Leaderboard query timed out at 90s; ROW_NUMBER rewrite took 380ms.

N
Naren Founder & Principal Engineer

20+ years shipping high-throughput database systems. Drawn from code that ran under real load.

Follow
Production
production tested
September 22, 2026
last updated
1,799
articles · all by Naren
Before you start⏱ 14 min
  • Basic SELECT, WHERE, ORDER BY, and LIMIT
  • GROUP BY aggregates and what they can't do
  • Terminal access to any Postgres (local or container)
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • psql introspection (\d, \dt, \df, information_schema) reveals tables, indexes, and functions in seconds without leaving the terminal
  • Top-N-per-group patterns: ROW_NUMBER() OVER (PARTITION BY), DISTINCT ON, and LATERAL joins — ROW_NUMBER wins for ranked leaderboards
  • Performance insight: a correlated-subquery leaderboard over 8M rows timed out at 90s; the ROW_NUMBER rewrite finished in 380ms with the right index
  • Production insight: DISTINCT ON without matching ORDER BY silently returns arbitrary rows — tests passed while leaderboards showed random 'winners'
  • Rule: inspect with \d first, prototype top-N with ROW_NUMBER, index (group, rank) columns, EXPLAIN before shipping
✦ Definition~90s read
What is PostgreSQL psql Introspection and Top N per Group?

psql is PostgreSQL's interactive terminal client: it connects to a database and offers backslash meta-commands (\d table describes a table, \dt lists tables, \df lists functions) plus full SQL with timing (\timing) and readable output (\x). It ships with every Postgres install and works over any SSH hop.

psql is the control panel wired straight into your Postgres database — dials and labels showing every table, column, and index without a web dashboard.

Top-N-per-group means returning the first N rows within each partition (top 3 products per category, latest 2 orders per user). The Stack Overflow canon answers converge on three SQL shapes: ROW_NUMBER() OVER (PARTITION BY grp ORDER BY rank) filtered to rn <= N (most flexible), DISTINCT ON (grp) (shortest, Postgres-only), and LATERAL subqueries (best with small group counts and good indexes).

Correlated EXISTS/subquery forms work but scale worst.

Plain-English First

psql is the control panel wired straight into your Postgres database — dials and labels showing every table, column, and index without a web dashboard. Top-N-per-group is the 'best 3 players per team' question: easy for one team, tricky for fifty at once. The naive way asks each team separately (fifty slow queries). The pro way ranks everyone in one pass with numbered jerseys per team (ROW_NUMBER), then keeps jerseys 1-3. One inspection habit plus one ranking trick covers the whole article.

It's 5 PM and someone asks for the top 3 products per category from an 8M-row table. Your first attempt — a subquery per group — is still running at 5:02. The dashboard times out, the requester pings again, and you're reading EXPLAIN output under pressure.

Both halves of this problem are daily Postgres life. You can't write the query if you can't see the schema fast, and you can't rank per group efficiently without window functions. psql's backslash commands handle the first; ROW_NUMBER handles the second.

Quick wins ahead. You'll get the dozen psql commands that replace dashboard-clicking, then five top-N patterns ranked by speed — including the 380ms rewrite that saved a launch-day leaderboard.

psql Introspection: See Everything in 60 Seconds

Connect (psql -h localhost -U shop shopdb) and orient with five commands: \dt shows tables, \d orders describes one table (columns, types, indexes), \di lists indexes, \df lists functions, \dn lists schemas. Sixty seconds, full map.

Go deeper without leaving the terminal: \d+ orders adds sizes and descriptions; \x on pivots wide rows readable; \timing times every query so optimizations are measurable, not vibes.

When backslash commands run out, query the catalogs directly: information_schema.columns for portable column lists, pg_stat_user_tables for seq-vs-index scan counts. Structure from \d, behavior from catalogs — the pair answers 'what exists' and 'what's slow.'

psql-tour.shBASH
1
2
3
4
5
psql -h localhost -U shop shopdb -c '\dt'              # all tables
psql -h localhost -U shop shopdb -c '\d orders'          # one table: cols + indexes
psql -h localhost -U shop shopdb -c '\di'                # all indexes
psql -h localhost -U shop shopdb -c '\df'                # functions
psql -h localhost -U shop shopdb -c 'SELECT tablename, seq_scan, idx_scan FROM pg_stat_user_tables ORDER BY seq_scan DESC LIMIT 5;'
📊 Production Insight
The launch postmortem started with \d listings + pg_stat_user_tables: missing composite index plus 100% seq scans visible in under a minute.
🎯 Key Takeaway
\dt/\d/\di/\df map structure; catalog views reveal behavior; \timing measures fixes.

Pattern 1: ROW_NUMBER — the Default Winner

ROW_NUMBER() OVER (PARTITION BY seller_id ORDER BY sales DESC) AS rn numbers each seller's listings 1..N by sales; the outer WHERE rn <= 5 keeps the top 5 per seller in one pass. Add id as tiebreaker (ORDER BY sales DESC, id) for determinism.

It handles ties explicitly (vs RANK's gaps), pages cleanly (rn BETWEEN 6 AND 10), and extends to latest-N-per-user with ORDER BY created_at DESC. One shape, many questions.

Cost is one sort per partition set — which the (seller_id, sales DESC) composite index largely eliminates. This is the pattern to reach for first and the one to benchmark others against.

topn_row_number.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- Top 5 listings per seller: one pass, deterministic ties
SELECT id, seller_id, title, sales
FROM (
  SELECT id, seller_id, title, sales,
         ROW_NUMBER() OVER (
           PARTITION BY seller_id
           ORDER BY sales DESC, id
         ) AS rn
  FROM listings
) ranked
WHERE rn <= 5
ORDER BY seller_id, rn;

-- Supporting index (build concurrently in prod)
CREATE INDEX CONCURRENTLY idx_listings_seller_sales
  ON listings (seller_id, sales DESC);
📊 Production Insight
This exact rewrite took the launch widget from 90s timeouts to 380ms P95 — same data, one pass instead of 12k subqueries.
🎯 Key Takeaway
PARTITION-rank-filter in one pass; tiebreak with id; index (group, rank).

Pattern 2: DISTINCT ON — Shortest, Postgres-Only

SELECT DISTINCT ON (seller_id) seller_id, id, sales FROM listings ORDER BY seller_id, sales DESC, id picks the first row per seller under that ordering. For top-1-per-group it's unbeatable: short, fast with the composite index, idiomatic Postgres.

Top-N beyond 1 needs a trick (DISTINCT ON doesn't take N) — which is why ROW_NUMBER owns N > 1. And the ORDER BY must lead with the DISTINCT expressions, or Postgres errors; mismatching them is the top DISTINCT ON bug.

Use it for latest-order-per-user, current-price-per-product, newest-event-per-device. One row per group, minimal SQL, maximum clarity — as long as you never need N = 5.

topn_distinct_on.sqlSQL
1
2
3
4
5
6
7
8
9
-- Newest order per user (top-1-per-group sweet spot)
SELECT DISTINCT ON (user_id) user_id, id, total, created_at
FROM orders
ORDER BY user_id, created_at DESC, id;

-- Top-1 product per category by sales
SELECT DISTINCT ON (category_id) category_id, id, title, sales
FROM listings
ORDER BY category_id, sales DESC, id;
📊 Production Insight
The 'current price per product' feed uses DISTINCT ON and has never broken — top-1 shapes stay readable where top-5 needs windows.
🎯 Key Takeaway
DISTINCT ON = top-1-per-group champion. ORDER BY must lead with the group key.

Patterns 3-4: LATERAL and Correlated (Know the Cliff)

LATERAL joins run a subquery per outer row with access to it: SELECT s., t. FROM sellers s, LATERAL (SELECT * FROM listings l WHERE l.seller_id = s.id ORDER BY sales DESC LIMIT 5) t. With few sellers and LIMIT-pushed indexes, it's excellent — each inner query is a tiny indexed lookup.

The cliff is group count: 12k sellers means 12k subplans per page view. Fine at 20 groups, fatal at 12k. Rule of thumb: LATERAL under hundreds of groups, ROW_NUMBER above.

Plain correlated subqueries (WHERE sales = (SELECT max...)) are worse — no LIMIT pushdown, full inner scans. They read naturally and scale poorest; treat them as prototypes to replace, not patterns to ship.

📊 Production Insight
Staging's 20-seller fixture made LATERAL look instant. Production's 12k sellers made it 90 seconds. Group count is the scaling variable that matters.
🎯 Key Takeaway
LATERAL for few groups with indexed LIMITs; ROW_NUMBER past hundreds; correlated as prototype only.

Pattern 5: Ties, Pages, and Freshness Correctly

Ties need policy: ROW_NUMBER picks arbitrarily among equals (add id tiebreak), RANK/DENSE_RANK share ranks with gaps or without. Leaderboards with prizes want DENSE_RANK (ties share the prize tier); feeds want ROW_NUMBER (stable order). Choose deliberately and test with tied fixtures.

Paging per group uses the rn column (rn BETWEEN 6 AND 10) — stable across inserts unlike LIMIT/OFFSET on the raw table. Freshness comes from WHERE created_at > now() - interval '30 days' inside the window subquery, so ranking considers only recent rows.

Cache the result: top-N leaderboards change slowly and cost real compute. The launch fix added 60s Redis caching — database load fell 97% on top of the 380ms query. Fast query plus short cache beats either alone.

topn_ties_pages.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
-- Shared ranks for prize tiers (ties share rank 1)
SELECT seller_id, id, sales,
       DENSE_RANK() OVER (PARTITION BY seller_id ORDER BY sales DESC) AS rnk
FROM listings;

-- Page 2 per seller (rows 6-10), recent listings only
SELECT id, seller_id, title, sales FROM (
  SELECT id, seller_id, title, sales,
         ROW_NUMBER() OVER (PARTITION BY seller_id ORDER BY sales DESC, id) AS rn
  FROM listings
  WHERE created_at > now() - interval '30 days'
) r WHERE rn BETWEEN 6 AND 10;
⚠ Ties without a tiebreaker are random winners
Equal sales with no id tiebreak return arbitrary rows per run. Tests passed with random champions for weeks before anyone noticed. Always end ORDER BY with a unique column.
📊 Production Insight
Prize-tier disputes traced to ROW_NUMBER splitting ties arbitrarily. DENSE_RANK plus documented tie policy ended the arguments.
🎯 Key Takeaway
Tiebreak with id, page with rn ranges, filter freshness inside, cache the widget.

EXPLAIN Before You Ship (5-Minute Habit)

Run EXPLAIN (ANALYZE, BUFFERS) on production-sized staging and read three lines: the join/sort method (Seq Scan on 8M rows is the red flag), actual rows vs estimates (10x gaps mean stale stats — run ANALYZE), and buffers hit vs read (disk-bound sorts need the index, not more RAM).

Compare shapes head-to-head with \timing: correlated vs ROW_NUMBER on the same snapshot, before/after the composite index. Numbers beat opinions — the 90s-to-380ms story was two EXPLAIN outputs pasted into the incident doc.

Make it a gate: leaderboard PRs include EXPLAIN ANALYZE output and row counts. Queries that can't show their plan don't merge. Five minutes per query prevents launch-day cascades.

📊 Production Insight
The merge gate caught a second 60s leaderboard in review — EXPLAIN showed the missing index before users ever could.
🎯 Key Takeaway
EXPLAIN (ANALYZE, BUFFERS) on real-sized data; compare shapes; gate merges on plans.
● Production incidentPOST-MORTEMseverity: high

The Launch-Day Leaderboard That Timed Out at 90 Seconds

Symptom
Ten minutes after launch, the marketplace homepage stalled: the 'top 5 per seller' widget timed out at 90s for every visitor, pg_stat_activity showed 400 concurrent copies of the same query, and connection pool exhaustion cascaded into checkout failures. The widget had passed QA on a 20k-row fixture in 60ms — production held 8M listings across 12k sellers.
Assumption
The author assumed a per-seller subquery (fast on 20 sellers in staging) would scale linearly, and that the ORM's N+1-ish SQL was 'just a bigger version of tested.' Nobody ran EXPLAIN on production-sized data or load-tested the widget endpoint before launch.
Root cause
The query ran a correlated LATERAL-ish subquery per seller without a supporting (seller_id, sales) index — 12k index-absent subplans per page view, each scanning. At 400 concurrent page views the database executed ~5M subplans/sec worth of demand against sequential scans. The missing composite index plus the per-group-subquery shape (rather than one window-function pass) multiplied cost by group count.
Fix
Rewrote as a single ROW_NUMBER() OVER (PARTITION BY seller_id ORDER BY sales DESC) subquery filtered to rn <= 5, added CREATE INDEX CONCURRENTLY ON listings (seller_id, sales DESC), and cached the widget 60s in Redis. P95 dropped from 90s timeouts to 380ms; connections fell from 400 to 12. EXPLAIN ANALYZE on production-sized staging is now a merge requirement for leaderboard queries.
Key lesson
  • Per-group subqueries multiply cost by group count — one window-function pass plus a (group, rank) index is the default shape.
  • Leaderboard queries need production-sized EXPLAIN and load tests; fixture-speed proves nothing at 8M rows.
Production debug guideFive Postgres ranking and introspection failures with fixes.5 entries
Symptom · 01
Top-N query returns different 'winners' on each run
Fix
Non-deterministic ORDER BY: ties in the rank column resolve arbitrarily. Add a unique tiebreaker (ORDER BY sales DESC, id) or use DISTINCT ON with the full ORDER BY matching. Tests with ties must assert stability.
Symptom · 02
DISTINCT ON returns wrong rows despite looking right
Fix
DISTINCT ON (grp) requires ORDER BY grp first, then rank — any other order is an error or arbitrary pick. Rewrite as DISTINCT ON (seller_id) ... ORDER BY seller_id, sales DESC and verify the leading key matches.
Symptom · 03
Window query is slow despite ROW_NUMBER looking correct
Fix
Missing (group, rank) composite index forces a sort of 8M rows. CREATE INDEX CONCURRENTLY ON t (grp, rank DESC), then EXPLAIN ANALYZE to confirm Index Scan + Sort elimination. Add \timing in psql to measure.
Symptom · 04
psql \d shows the table but not sizes, bloat, or index usage
Fix
Drop to SQL: pg_total_relation_size, pg_stat_user_tables (seq_scan vs idx_scan), and pg_stat_user_indexes. \d is structure; the catalog views are behavior — you need both.
Symptom · 05
LATERAL query fast for 10 groups, dead for 10k groups
Fix
LATERAL scales with group count (one subplan each). Above hundreds of groups, switch to the single-pass ROW_NUMBER shape. Keep LATERAL for small group sets with highly selective inner LIMITs.
Top-N-per-Group Patterns Compared
PatternBest forScales toCaveat
ROW_NUMBER + filterTop-N, pages, ties controlMillions of rowsNeeds (group, rank) index
DISTINCT ONTop-1 per groupMillions of rowsPostgres-only; N=1 only
LATERAL + LIMITFew groups, indexed innerHundreds of groupsCost × group count
Correlated subqueryPrototypesTiny dataWorst scaling; replace it
RANK / DENSE_RANKPrize tiers with tiesMillions of rowsGaps (RANK) vs none (DENSE)
⚙ Quick Reference
4 commands from this guide
FileCommand / CodePurpose
psql-tour.shpsql -h localhost -U shop shopdb -c '\dt' # all tablespsql Introspection
topn_row_number.sqlSELECT id, seller_id, title, salesPattern 1: ROW_NUMBER
topn_distinct_on.sqlSELECT DISTINCT ON (user_id) user_id, id, total, created_atPattern 2: DISTINCT ON
topn_ties_pages.sqlSELECT seller_id, id, sales,Pattern 5

Key takeaways

1
\d/\dt/\di plus catalog views map any database in a minute; \timing measures fixes.
2
ROW_NUMBER PARTITION-rank-filter is the default top-N shape; index (group, rank).
3
DISTINCT ON owns top-1; LATERAL owns few-group indexed lookups; correlated owns prototypes.
4
Always tiebreak with a unique key; test ties explicitly or leaderboards lie randomly.
5
Gate leaderboard PRs on production-sized EXPLAIN and cache the widget briefly.

Common mistakes to avoid

4 patterns
×

Per-group subqueries without checking group count

Symptom
60ms on 20 staging sellers, 90s timeouts on 12k production sellers.
Fix
Default to single-pass ROW_NUMBER; reserve LATERAL for hundreds of groups or fewer.
×

ORDER BY ties with no unique tiebreaker

Symptom
Different 'winners' every run; tests pass while leaderboards lie.
Fix
End every ranking ORDER BY with id (or another unique key); test with tied fixtures.
×

DISTINCT ON with mismatched ORDER BY lead key

Symptom
Errors or arbitrary rows that look plausible in review.
Fix
ORDER BY must start with the DISTINCT ON expressions, then rank columns.
×

Shipping leaderboard queries without production-sized EXPLAIN

Symptom
Missing composite index found by users, not review.
Fix
Require EXPLAIN (ANALYZE, BUFFERS) + \timing on realistic data in every leaderboard PR.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
How do you get the top 3 rows per group in PostgreSQL?
Q02SENIOR
When is DISTINCT ON better than ROW_NUMBER?
Q03SENIOR
Why did a fast staging leaderboard time out in production?
Q01 of 03SENIOR

How do you get the top 3 rows per group in PostgreSQL?

ANSWER
ROW_NUMBER() OVER (PARTITION BY grp ORDER BY rank DESC, id) in a subquery, filter rn <= 3 outside, with a composite index on (grp, rank DESC). Single pass, deterministic with the id tiebreak, pages via rn ranges.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What does \d do in psql?
02
How is DISTINCT ON different from GROUP BY?
03
ROW_NUMBER vs RANK vs DENSE_RANK — which for leaderboards?
04
Why is my window query still slow?
05
Can I do top-N-per-group without window functions?
N
Naren Founder & Principal Engineer

20+ years shipping high-throughput database systems. Drawn from code that ran under real load.

Follow
Verified
production tested
September 22, 2026
last updated
1,799
articles · all by Naren
🔥

That's PostgreSQL. Mark it forged?

3 min read · try the examples if you haven't

Previous
SQL ALTER TABLE Add Column
1 / 1 · PostgreSQL
Next
SQL Top N per Group Greatest N per Group