PostgREST caps responses at max_rows; page with range() and a count

The default cap silently truncates unpaged lists at 1,000 rows. Count with head: true, page with inclusive range(), and embed related rows in one select.

Data API

· Chapter

28

·

3

min read

The answer. Every Data API response is capped by the project's max rows setting (Settings → API), 1,000 by default, and a select() without paging returns the first thousand with no hint that more exist. Treat the cap as a blast-radius limit (lower it to what one screen can use) and page on purpose. count: "exact", head: true returns the total without transferring rows; range(from, to) is zero-based and inclusive on both ends; order() calls chain for multi-column sorts, and an enum column sorts in its definition order. Filters chained on the builder combine with AND; a multi-column search is one or() filter string in PostgREST syntax. Related rows come along in the same request through embedding, which follows foreign keys: select("*, comments(*)"), ordered per embedded table, !inner when the parent should only appear if the child exists.

The pattern.

const page = 2, size = 20, from = (page - 1) * size, to = from + size - 1;
let q = supabase.from("tickets").select("id, title, status, author:profiles!created_by(full_name), comments(count)", { count: "exact" })
  .eq("tenant", tenant).order("status").order("created_at", { ascending: false }).range(from, to);
if (term) q = q.or(`title.ilike.%${clean(term)}%,description.ilike.%${clean(term)}%`);   // clean(): strip " \ % ,
const { data, count } = await q;
const hasMore = (count ?? 0) > page * size;
alias:table!fk_column(*)     disambiguate two FKs to the same table
comments!inner(*)            keep only parents with at least one child
order("created_at", { referencedTable: "comments" })   order the embedded rows

Watch out.

  • Sanitize search input before building or() strings; , " \ % all mean something to the parser.
  • count: "exact" scans; on large tables use "planned" or "estimated" for the paginator and exact only where the number is shown.
  • range() past the end returns an empty array with status 416 in some clients; compute hasMore from the count, not from the fetched length.

Related: expose-only-an-api-schema-to-postgrest · postgrest-pre-request-function-is-your-api-middleware · realtime-postgres-changes-vs-broadcast-from-triggers