Aggregation, Window Functions, and Pagination
Rule
Always pair LIMIT with a deterministic ORDER BY, paginate application-facing lists by keyset, and use window functions instead of self-joins for ranking and running values.
Why
LIMIT without a total order returns arbitrary rows that shift between requests. OFFSET scans and discards everything it skips, so deep pages get slower and drift under concurrent writes. Window functions state analytic intent directly.
Do
- Write an explicit
ORDER BYwith a deterministic tiebreaker (appendid) on every query withLIMIT. - Paginate application-facing lists by keyset:
WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT $3;idvalues (uuidv7, time-ordered) make workable cursors. - Use window functions for ranking, running totals, and neighbors:
row_number(),rank(),sum() OVER,lag()/lead(). - Name repeated window definitions once with
WINDOW w AS (...). - Group by the natural key columns; keep
HAVINGfor conditions on aggregates andWHEREfor row filters. - Use
count(*)for row counts;count(col)only when skipping NULLs is the point. - Use
filter (WHERE ...)for conditional aggregates instead ofsum(CASE WHEN ... THEN 1 ELSE 0 END).
Avoid
- Do not use
OFFSETfor application-facing pagination; reserve it for small, bounded admin/reporting pages. - Do not implement top-N-per-group with self-joins; use
row_number()orLATERAL(see subqueries, EXISTS, and LATERAL). - Do not rely on
DISTINCTto clean up row multiplication from a wrong join; fix the join. - Do not use
GROUP BY 1, 2ordinal references in committed SQL; name the columns. - Do not compute aggregates the page does not display.
Example
-- Keyset pagination: page 2 continues after the last row of page 1.
SELECT o.id, o.created_at, o.total
FROM orders o
WHERE o.user_id = $1
AND (o.created_at, o.id) < ($2, $3)
ORDER BY o.created_at DESC, o.id DESC
LIMIT 25;
-- Each user's three largest paid orders: window function.
WITH ranked_orders AS (
SELECT
o.user_id,
o.id,
o.total,
row_number() OVER (PARTITION BY o.user_id ORDER BY o.total DESC, o.id) AS rank
FROM orders o
WHERE o.state = 'paid'
)
SELECT ro.user_id, ro.id, ro.total
FROM ranked_orders ro
WHERE ro.rank <= 3;
Exceptions
- Numbered-page UIs over small bounded sets (admin tables, reports) may use
LIMIT/OFFSET; keep the deterministicORDER BY. - Approximate counts for very large tables may read
pg_class.reltuplesinstead ofcount(*)when exactness is not required; comment the approximation.