Skip to content
EL
Contents

Pagination and large result sets

Review a paginated endpoint

DecisionCore4 min read15 min hands-on#sql#indexing

The idea in 30 seconds

Paginated endpoints are usually written once and then live for years while the table grows. Review them for what they will do at a million rows: unique ordering, a matching index, a cursor instead of an offset, and a total count only if someone really needs it.

Helps to know first: B-tree indexes, Set operations and advanced expressions

On this page

The situation

The shop is adding an activity log per organization. An AI assistant implements the endpoint.

Review taskActivity log endpoint

Pull request: “GET /orgs/:id/events?page=N with total pages.”

CREATE TABLE events (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  org_id      integer NOT NULL,
  created_at  timestamptz NOT NULL DEFAULT now(),
  payload     text NOT NULL
);

-- page N, 20 per page
SELECT id, created_at, payload FROM events
WHERE org_id = $1
ORDER BY created_at DESC
LIMIT 20 OFFSET ($2 - 1) * 20;

-- for "page N of M"
SELECT count(*) FROM events WHERE org_id = $1;

Would you approve it? Write the review you would leave.

Your call

Checklist for any paginated query

  1. Is the ORDER BY unique? If not, add the primary key as the last column.
  2. Is there an index on the filter columns plus the sort columns, in that order?
  3. Keyset or offset? Keyset for feeds, logs and APIs; offset only where users jump to numbered pages, and cap how deep they can go.
  4. Does every page really need a total count?
  5. What happens at 100× today’s rows? Measure page 1 and a deep page with EXPLAIN (ANALYZE, BUFFERS).

Check yourself

  1. Why can ORDER BY created_at DESC alone repeat or lose rows between pages?

    Show answer

    Rows with the same created_at have no defined relative order, so across two queries a tied row can land on either side of the page boundary: shown twice or not at all.

  2. How can an endpoint tell the client there are more results without counting all rows?

    Show answer

    Fetch one row more than the page size. If the extra row exists, there is a next page; drop it before returning.

  3. Why is an activity log a particularly bad fit for OFFSET?

    Show answer

    It grows forever, so deep pages get slower and slower, and new events keep arriving at the top while users page, shifting positions and causing repeated or skipped rows.

Finished reading?

Saved in this browser only.

Sources

Where each claim on this page comes from.

  1. 1.
    PostgreSQL documentation: PREPARE
    Primary· PREPARE, Notes

    With plan_cache_mode set to auto, the first five executions of a prepared statement use custom plans, after which the server may switch to a generic plan.

  2. 2.
    PostgreSQL documentation: 7.6 LIMIT and OFFSET
    Primary· §7.6 LIMIT and OFFSET

    Skipped rows are still computed; paging needs an ORDER BY that gives a unique order.

  3. 3.
    PostgreSQL documentation: 11.4 Indexes and ORDER BY
    Primary· §11.4

    An index matching the ORDER BY lets the first rows be read directly, without sorting everything.

Go further

What to read next, and why it is worth the time.

  1. 1.
    Use The Index, Luke!: We need tool support for keyset pagination
    ReferenceMarkus Winand· location not yet verified

    Shows how keyset cursors are exposed in APIs and front-end libraries.

Applies to PostgreSQL 18. Draft: not yet fact-checked end to end.