Why does a Supabase query silently return only 1000 rows?

PostgREST caps unbounded selects and reports success. What that does to a cron job, and the helper that makes the failure impossible.

Answer

PostgREST applies a default maximum row count — commonly 1000 — to unbounded selects and returns success. Your query gets the first 1000 rows and no error. In a cron job this means the tail of the table is never processed, and because the ordering is stable it is always the same tail, so the same users stop being served.

LM

Leutrim Miftaraj

Founder, SubTracker · Updated September 5, 2026

The short answer

This degrades gradually rather than breaking. The job works at 500 rows, works at 900, and starts truncating at 1100 with no signal. The users who stop being served are always at the end of the ordering, so nobody who is affected is anybody who would notice.

What actually happens

""`ts const { data } = await supabase.from('reminders').select('*').eq('is_active', true) ""`

If the table has 3,000 matching rows, "data" has 1,000. "error" is null. The response is a success.

There is a "Content-Range" header that tells you the truth, and the client libraries do not surface it in a way anybody reads. So the calling code sums, iterates or sends over a third of the data and reports completion.

In a user-facing list this is a display bug someone notices. In a scheduled job it is a silent leak.

Why it is worse in a cron

Three properties combine badly.

It degrades gradually. The job is correct while the table is small, and stays correct through the period when you are testing it. It starts truncating at some point after launch, with no deploy and no change to blame.

The truncation is stable. With a deterministic ordering, the same rows are cut every run. So the same users never get their reminder, every time, forever.

Nobody affected can report it. A user who is not receiving a reminder does not know a reminder was due. There is no error, no bounce, no support ticket — the absence of an email is not an event.

We ran this for a period before finding it. The trigger was not a report; it was a mismatch between the row count in the admin panel and the number the job said it had processed.

The helper

Pagination is the fix, and it belongs in one place so no call site can forget it.

""`ts const PAGE = 1000

export async function selectAllRows<T>( query: () => PostgrestFilterBuilder<any, any, T[]>, label: string, ): Promise<T[]> { const out: T[] = [] for (let from = 0; ; from += PAGE) { const { data, error } = await query().range(from, from + PAGE - 1) if (error) { // Log with a label so the source is identifiable, and return what we // have rather than throwing — a partial result that is reported is // better than a job that dies mid-run. console.error("[selectAll] ${label}:", error) return out } if (!data?.length) break out.push(...data) if (data.length < PAGE) break } return out } ""`

Two deliberate choices. A required label, so an error line identifies which query truncated without a stack trace. And returning partial results rather than throwing, because in a cron a partial run that reports itself is more useful than an exception that loses the work already done.

The ordering must be deterministic for this to be correct — ".order('id')" on the passed query, not left to chance. Without it, rows can repeat or be skipped across pages.

Making the mistake impossible

A helper only helps if it is used. Two things enforce it.

A test that walks the source. Any ".from('<growing table>')" followed by a select-and-filter chain, in a file that does not import "selectAllRows", fails the build. Blunt, occasionally annoying, and it has caught two regressions.

Report the count in the run record. Every scheduled job writes how many rows it processed and whether it finished. A number that stops growing while the table grows is visible in a way a silent truncation is not.

That second one generalises past this bug and is the more valuable habit: a job that reports what it did can be checked, and a job that only reports that it ran cannot.

The general shape

This is one instance of the pattern that has caused most of our production incidents: an operation that returns success while doing part of the work, or none of it.

The others were a transactional email provider accepting a request and not delivering, a payment webhook handler catching its own exception and returning 200, and a cron job exhausting its time budget mid-list with no record of where it stopped.

The defence is the same in every case. Design so that "did this actually happen, and to how many" is answerable from stored state — a row count, a delivery outcome, a cursor — rather than inferred from the absence of an exception.

Die ehrliche Bau-oder-Kauf-Linie

Das Schema ist der einfache Teil. Es ist auch der Teil, der sich nach Fortschritt anfühlt — weshalb das Projekt unmittelbar danach aufhört, Spass zu machen.

Was tatsächlich Zeit kostet, in der Reihenfolge, in der es zubeisst: geplante Erinnerungen mit Idempotenz und Zeitzonen, Wiederholungs-Arithmetik über unregelmässige Intervalle, Währungsbehandlung, und ein Import, aus dem heraus überhaupt etwas entsteht.

Nichts davon ist intellektuell schwierig. Es ist schwierig im Sinne von vier Wochenenden, und das vierte ist das, an dem das Projekt stirbt.

Wenn das Ziel Lernen ist: bau es. Der Code hier ist echt und funktioniert. Wenn das Ziel ist, kein Geld mehr an vergessene Abos zu verlieren, ist der Bau ein Umweg.

Was SubTracker damit macht

Zur Einordnung, weil die Entscheidungen oben aus einem laufenden System stammen und nicht aus einer Entwurfsübung.

SubTracker ist eine Next.js- und PostgreSQL-Anwendung (Supabase), die Abos manuell verfolgt — es gibt keine Bankverbindung, jede Zeile stammt aus einem Formular oder einem CSV-Import. Diese Einschränkung prägt das Schema: kein „erkannt"-Zustand, kein Konfidenzwert, keine Händler-Zuordnungstabelle. Jede Zeile ist eine Behauptung, die jemand aufgestellt hat.

Der kostenlose Plan verfolgt unbegrenzt viele Abos inklusive CSV-Import und Live-Kalender-Feed. Erinnerungen und Preis-Alarme sind Plus für $5.90/Monat; Family kostet $9.90/Monat und fügt einen geteilten Workspace für bis zu zehn Personen hinzu — an dem Punkt hören die Besitzverhältnisse im Schema auf, theoretisch zu sein.

Frequently asked questions

Why does my Supabase query return exactly 1000 rows?+

PostgREST applies a default maximum row count to unbounded selects and returns success. The Content-Range header carries the truth but the client libraries do not surface it prominently, so the calling code proceeds with partial data and no error.

How do you fetch all rows from a Supabase table?+

Paginate with range() in a loop until a page comes back short, with a deterministic order clause so rows are neither repeated nor skipped across pages. Put it in one shared helper so no call site can forget.

Why is silent truncation worse in a scheduled job?+

Because it degrades gradually, always cuts the same tail with a stable ordering, and produces no event anybody can report — a user not receiving a reminder does not know one was due.

How do you catch this class of bug generally?+

Make "did this happen, and to how many" answerable from stored state. Every job should record rows processed and whether it completed; a count that stops growing while the table grows is visible in a way a silent truncation is not.