PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 19: Finding and fixing slow queries

Rewrite patterns that make queries fast

Level 4: Performance25 min

Many slow queries are fixed by rewriting, not by indexing: keeping predicates sargable, paging by key instead of offset, replacing OR across columns, batching instead of looping, and estimating counts instead of counting.

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
  • Make predicates index-friendly (sargable)
  • Replace OFFSET paging with keyset pagination
  • Recognise N+1 queries, OR across columns and exact counts as performance smells

conceptThe concept

Sargable predicates compare a bare column with a value: created_at >= $1, not date(created_at) = $1; price > 50, not price * 2 > 100. Wrapping the column hides it from plain indexes and from its statistics.

Keyset pagination remembers the last row seen: WHERE (created_at, id) < ($last_ts, $last_id) ORDER BY created_at DESC, id DESC LIMIT 20. With an index on (created_at, id) every page costs the same, while OFFSET 100000 reads and discards 100,000 rows.

N+1: fetching a list and then one query per item. Replace with a join, = ANY($1::int[]) or LATERAL. OR across columns often prevents a single index scan; UNION of two indexed queries, or BitmapOr over two indexes, fixes it. Exact counts of big tables are expensive in MVCC; for UI badges use reltuples or an EXPLAIN estimate.

whyWhy it matters

Indexes cannot help a query that hides its columns inside functions or asks for page 5,000 by offset. These rewrites are cheap, safe and usually the biggest wins in application code reviews.

Use caseReal-world scenario

The admin "all orders" screen slows down linearly as users page further. Keyset pagination makes page 500 as fast as page 1.

Try itExamples

Run and edit these in the interactive lesson.

Sargable versus non-sargable
BEGIN;
CREATE INDEX orders_created_idx ON orders (created_at);
EXPLAIN SELECT count(*) FROM orders WHERE created_at::date = '2025-06-01';
EXPLAIN SELECT count(*) FROM orders WHERE created_at >= '2025-06-01' AND created_at < '2025-06-02';
ROLLBACK;
The cast hides the column, so the first query scans the whole table; the half-open range uses the index.
OFFSET versus keyset pagination
BEGIN;
CREATE INDEX orders_created_id_idx ON orders (created_at DESC, id DESC);
EXPLAIN (ANALYZE, COSTS OFF)
SELECT id, created_at FROM orders ORDER BY created_at DESC, id DESC OFFSET 8000 LIMIT 20;

EXPLAIN (ANALYZE, COSTS OFF)
SELECT id, created_at FROM orders
WHERE (created_at, id) < ('2024-04-21 12:00+00', 1576)
ORDER BY created_at DESC, id DESC LIMIT 20;
ROLLBACK;
The OFFSET plan produces 8,020 rows to return 20. The keyset plan starts right at the boundary and reads 20.
Batching instead of N+1
-- one query for many ids instead of one query per id
SELECT o.id, o.status, o.total
FROM orders o
WHERE o.id = ANY ('{11,25,37,98,1203}'::int[])
ORDER BY o.id;
Fast approximate counts
SELECT reltuples::bigint AS estimated_rows FROM pg_class WHERE oid = 'events'::regclass;
SELECT pgm.estimate('SELECT * FROM events WHERE kind = ''search''') AS planner_estimate,
       (SELECT count(*) FROM events WHERE kind = 'search') AS exact;

Avoid / preferDo this, not that

Avoid
SELECT count(*) FROM orders WHERE extract(year FROM created_at) = 2025;
Prefer
SELECT count(*) FROM orders
WHERE created_at >= '2025-01-01' AND created_at < '2026-01-01';

A function on the column makes the predicate non-sargable: no plain index can serve it. The range form can.

Avoid
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 100000;
Prefer
SELECT * FROM orders
WHERE (created_at, id) < ($last_created_at, $last_id)
ORDER BY created_at DESC, id DESC LIMIT 20;

OFFSET reads and discards every skipped row, so deep pages get slower and slower. Keyset pagination starts from where the last page ended.

PitfallsCommon mistakes

  • Applying functions or casts to indexed columns in WHERE.
  • Using OFFSET for deep pagination or infinite scroll.
  • Running SELECT count(*) on a large table to show "1,234,567 results" on every page view.

LimitsWhen not to use it

Keyset pagination cannot jump to page 500 directly. For admin screens that need arbitrary page numbers on small result sets, OFFSET is fine.

SecuritySecurity notes

Large user-supplied OFFSET or LIMIT values are a cheap denial-of-service. Cap page sizes and depth on the server side.

ChallengeTry it yourself

Fetch the 5 orders that come after order 5000 in a newest-first listing, using keyset pagination.

Show one solution
One solution
SELECT id, created_at
FROM orders
WHERE (created_at, id) < (SELECT created_at, id FROM orders WHERE id = 5000)
ORDER BY created_at DESC, id DESC
LIMIT 5;

InterviewQuestions you should be able to answer

Why is OFFSET pagination slow and what do you use instead?
OFFSET n still produces and discards the first n rows, so cost grows linearly with page number, and rows shift between pages when data changes. Keyset (seek) pagination filters on the last seen sort key, WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT 20, with a matching composite index. Each page is an index range scan of 20 entries. The trade-off is no random jump to page N.
Why is count(*) slow on large PostgreSQL tables?
Because of MVCC, visibility is per row version and per transaction, so the server cannot keep a single exact row count; it must scan the table or an index (an index-only scan helps when the visibility map is current). For approximate counts use pg_class.reltuples or the planner's estimate from EXPLAIN; for exact frequently needed counts maintain a counter table updated transactionally.

QuizCheck your understanding

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

Go deeperOfficial documentation