Notes / Supabase data
Why your Supabase app stops at 1,000 rows, and never tells you
Totals that stop growing. Exports that are always exactly a thousand lines. No error anywhere. Here is what is happening and how to fix it properly.
This one is quiet enough to run for months. The revenue figure on your dashboard looks plausible. The export looks complete. Everything loads fast. But every number has been worked out from, at most, the first 1,000 rows, and nothing in the app can tell the difference between "this is all of it" and "this is where it stopped".
What is going on
Supabase's API caps how many rows a single request can return. The setting is called Max rows, and it defaults to 1,000. Ask for 14,000 orders and you get 1,000, in a perfectly normal, successful response.
That is not a Supabase bug. It is a sensible default that stops one careless query from dragging your whole database across the network. The bug lives in code that asks for rows and assumes it received all of them, which is exactly the code an AI assistant writes when you ask it to "show total revenue".
How to check your own app
- Open your live app in Chrome and press F12 to open developer tools.
- Go to the Network tab and reload the page that shows your totals, or run your export.
- Type
rest/v1into the filter box. Those are the requests to your database. - Click one and open the Response tab. If it returns exactly 1,000 items, you have found it.
In your code, look for any place that fetches rows and then counts, sums or averages them in JavaScript. If that table can ever grow past a thousand rows, the number will eventually be wrong.
Fixing it properly
If you only need a count
Ask the database for the count instead of the rows:
const { count, error } = await supabase
.from('orders')
.select('*', { count: 'exact', head: true });head: true means no rows come back at all, only the number.
If you need a total or an average
Do the maths inside Postgres, where every row is available. Create a function in the SQL Editor:
create or replace function public.revenue_between(start_at timestamptz, end_at timestamptz)
returns bigint
language sql
stable
security invoker
as $$
select coalesce(sum(amount), 0)
from public.orders
where created_at >= start_at and created_at < end_at;
$$;Then call it from your app:
const { data: revenue, error } = await supabase.rpc('revenue_between', {
start_at: '2026-09-01',
end_at: '2026-10-01',
});security invoker matters here. It means the function runs with the caller's permissions, so your row-level security still applies. A function marked security definer would ignore your policies entirely.
If you genuinely need every row
Page through them in chunks, in a fixed order:
const pageSize = 1000;
const rows = [];
for (let from = 0; ; from += pageSize) {
const { data, error } = await supabase
.from('orders')
.select('id, amount, created_at')
.order('id')
.range(from, from + pageSize - 1);
if (error) throw error;
rows.push(...data);
if (data.length < pageSize) break;
}The .order('id') is not optional. Without a fixed order, rows can move between pages and you end up with duplicates in one place and gaps in another.
What about just raising the limit?
You can change Max rows in your project's API settings, and for an internal admin tool that might be fine. For anything public it moves the ceiling rather than removing it, and it makes every big request slower and heavier. Counting and summing in the database is the real fix.
I look for this early in any AI-built app, because it never throws an error. If your numbers have ever looked a little too round, it is worth ten minutes to check.