Why does Supabase return only 1,000 rows without an error? (PostgREST max rows)

Supabase caps every API response at 1,000 rows by default, with no error. How the max rows limit skews an on-screen total, and how to sum in the database.

Pedro Henrique Quadro4 min read

Supabase returns at most 1,000 rows per query, and the limit applies to the whole API. If you ask for 20,000 with .limit(20000), the request is not rejected: you get a slice back, and any sum or count computed on top of it is wrong with no error anywhere. The fix is to sum in the database, or to paginate and compare against the real total.

What the documentation says

I checked the sources on October 10, 2026. The Supabase reference says that "By default, Supabase projects will return a maximum of 1,000 rows", that this can be changed in the project's API settings, and that the way to read more is to paginate with range().

Under the Supabase API sits PostgREST, the program that turns Postgres tables into HTTP endpoints. Its setting is called db-max-rows, and the documentation describes it as "a hard limit" on the number of rows it fetches from a view, table or function. The stated reason is to limit response size against accidental or malicious requests. On that page, the default for plain PostgREST is no limit. The 1,000 comes from Supabase, and on a self-hosted Supabase the value is whatever is in the container's PGRST_DB_MAX_ROWS variable.

I did not find documentation on whether the cut-off raises any warning. In what I measured, it does not.

What I saw in production

On a report screen I maintained, the query read the raw events of the last 7 days with .limit(20000) and summed them in code. The PostgREST container ran with PGRST_DB_MAX_ROWS=1000. There were 5,263 events in the period, the screen received the 1,000 most recent, and it showed 736 actions of a certain type in 7 days. The database had 3,403.

The number looked plausible because the 1,000 most recent rows were almost all of one type, and the type that mattered showed up rarely, but it did show up. Nobody compared the on-screen total with a count in the database.

The same problem showed up in a neighbor on the same day. A function (RPC) that returns one row per event is also cut. The one behind a logged-in area sent 1,000 out of 22,497 events, all of a type I later started filtering out. With the filter applied, the list came back empty and the whole block disappeared from the screen, even though the database held 17,626 records. I had fixed the queries that hit tables directly and forgotten the functions.

There was a third form. A "last access per account" calculation read all login rows ordered by time and kept the first one per account. With 1,260 logins, the account that had logged in longest ago fell outside the first 1,000 and showed up as "never logged in".

Why it happens

The client's .limit() can only ask for less than the ceiling, never more. The ceiling belongs to the server. Whoever writes the code sees a big number in .limit() and assumes they are asking for everything. The response comes back with status 200 and a list of plausible size, so nothing looks wrong. Summing a slice as if it were the whole gives a number that looks true.

What to do

  1. Sum in the database. Instead of fetching rows and summing in code, use a function that returns the aggregate:
create function eventos_por_tipo(desde timestamptz)
returns table (tipo text, total bigint)
language sql stable as $$
  select tipo, count(*) from eventos
  where criado_em >= desde
  group by tipo
$$;
  1. If you need the raw rows, ask for the total alongside them with count: 'exact' and compare it with the size of what came back. The PostgREST documentation says the Content-Range header reports the returned range and Prefer: count=exact returns the real total, at the cost of a slower query on a large table.
  2. Or paginate with range(), always with order defined, because the range follows the ordering and the documentation warns that without it the result can vary.
  3. "The latest row per group" built in code over an unfiltered select has the same flaw. Run a query with limit 1 per group, or use distinct on in the database.
  4. When fixing a screen, also sweep the functions that return one row per record, not only the .from() calls.
  5. To prove it is right, compare the number on screen with a count taken directly in the database, and measure through the API with the same access role as the user. Measuring only in the database terminal does not go through PostgREST and hides the ceiling.

To check the ceiling on a self-hosted Supabase, look at the PGRST_DB_MAX_ROWS variable of the API container. On hosted Supabase, the setting lives in the project's API settings.

If you have a dashboard whose totals do not match the database, that is the kind of thing I investigate in dashboards and data.

Sources

  1. Supabase: by default a project returns at most 1,000 rows, adjustable in the API settings, and range() for pagination
  2. PostgREST: db-max-rows is a hard limit on rows per view, table or function
  3. PostgREST: Content-Range reports the returned range and Prefer count=exact returns the total
  4. Supabase: range() uses positions starting at 0 and depends on ordering
Pedro Henrique QuadroSoftware and AI engineer. Builds apps, systems, dashboards and AI agents in production, with code merged into Supabase, Kestra and QuestDB.

Keep reading