Supabase maybeSingle Multiple Rows: Why It Returns Null
maybeSingle() silently returns null instead of throwing an error when multiple rows match. Here's what's actually happening, why PGRST116 fires, and the exact fix.
Supabase .maybeSingle() returns null with multiple rows, and your payments logic quietly breaks
You expect supabase maybeSingle multiple rows to either work or scream at you, not fail silently. Instead, under the wrong conditions it returns null, your logic assumes "no row", and things break in very non-obvious ways
Time to read: ~8 minutes.
In this post I'll walk through how .maybeSingle() broke my Stripe payment verification in production, why multiple rows can lead to null without a clear error, and how to refactor your queries so this never happens to you again.
The real bug: paid user, 402 Payment Required
The symptom was simple: a user paid on Stripe, but my API kept returning 402 Payment Required when they tried to access their report. Stripe showed a fully successful checkout session and payment intent for that user, so billing itself was fine
On the Supabase side, I had a report_payments table where each row mapped a Stripe session to a user and a report. The API used a query like this (simplified):
const { data: payment, error } = await supabase
.from('report_payments')
.select('*')
.eq('user_id', userId)
.eq('report_id', reportId)
.maybeSingle()
if (error) {
throw error
}
if (!payment) {
// No payment → block access
throw createHttpError(402, 'Payment Required')
}
In my head, .maybeSingle() meant: "give me the row if it exists, otherwise null". That matched the mental model "0 or 1 payment per report per user". The problem? For this user, there were actually 71 duplicate rows in report_payments for the same report and user.
What Supabase .maybeSingle() actually does
The Supabase docs describe .maybeSingle() as a way to "retrieve zero or one row of data", and explicitly note that the query result must be zero or one row. Under the hood, these helpers are thin wrappers around PostgREST's "singular vs plural" mode.
Conceptually, Supabase's JS client uses these rules for .maybeSingle():
- If exactly one row matches → return that row as an object.
- If zero rows match → return
null. - If more than one row matches → behavior depends on version/config; historically it returns an error, but there are configurations and patterns where you just see
nullplus an error or a confusing status.
The key issue: if your code treats null as "no rows, everything is fine, carry on", you're implicitly assuming your data is clean. The moment duplicates sneak in, null becomes "zero or ambiguous", not "definitely zero".
In my case, I wasn't even checking error once I saw payment === null. I simply assumed "no payment" and returned 402. The user had actually paid 71 times according to my database; my API was the one lying.
How I ended up with 71 duplicate payments
The root cause was not Supabase, it was my Stripe webhook handler Stripe can resend the same event multiple times: network glitches, timeouts, or retries on their side can all trigger duplicate deliveries for the same checkout.session.completed event.
My handler did something like this:
// Inside /api/stripe/webhook
if (event.type === 'checkout.session.completed') {
const session = event.data.object
const { error } = await supabase
.from('report_payments')
.insert({
user_id: session.metadata.user_id,
report_id: session.metadata.report_id,
session_id: session.id,
amount: session.amount_total,
currency: session.currency,
})
if (error) {
console.error('Failed to store payment', error)
}
}
There was no deduplication logic based on session.id, even though that's the natural unique key for a checkout session. If Stripe sent the same event multiple times, I happily inserted multiple rows. Over time, one user ended up with 71 identical report_payments rows.
Given those 71 matches, .maybeSingle() no longer had a clear "single row" to return. It collapsed to null, and my API translated that into "no payment".
Why .maybeSingle() is dangerous in critical code paths
.maybeSingle() is very convenient when you truly have a "maybe 0, maybe 1" relationship you fully control. It becomes dangerous when:
- You have no unique constraint at the database level enforcing that "at most one row" invariant.
- You treat
nullas "safe and expected", not "possibly ambiguous". - You don't log or react to the error object when multiple rows match.
Payments, auth, and anything money-related are the last places you want ambiguous behavior. A single wrong null in those paths can mean:
- Blocking paying users.
- Creating duplicate sessions or resources.
- Granting access based on the "wrong" row if you later switch back to
.single().
The general rule I now follow:
nullfrom.maybeSingle()is not proof that no row exists. It only means Supabase couldn't give you exactly one row.
If that distinction matters for the business logic (and for money it does), you need a stricter approach.
The fix: stop trusting maybeSingle() alone
I fixed the bug in two layers: first at the query level, then at the Stripe webhook level.
Validating your SaaS idea before building saves more time than fixing bugs after.
Get your market research report in 3 mins → readytorelease.online
Generate Report1. Replace maybeSingle() with .limit(1) + length check
Instead of:
const { data: payment, error } = await supabase
.from('report_payments')
.select('*')
.eq('user_id', userId)
.eq('report_id', reportId)
.maybeSingle()
I switched to:
const { data: payments, error } = await supabase
.from('report_payments')
.select('*')
.eq('user_id', userId)
.eq('report_id', reportId)
.limit(1)
if (error) {
throw error
}
if (!payments || payments.length === 0) {
// Definitely no rows
throw createHttpError(402, 'Payment Required')
}
const payment = payments
This keeps data as an array and makes the cardinality explicit:
payments.length === 0→ no rows.payments.length >= 1→ at least one row; pick the first, or log a warning if you ever see> 1.
This approach also matches the Supabase docs note that .maybeSingle() expects queries that already constrain results to zero or one row, for example using .limit(1). Without that, you're assuming cleanliness that your database may not guarantee.
If you want to be extra strict in critical flows, you can explicitly detect duplicates:
if (payments.length > 1) {
console.error(
`Duplicate payments detected for user=${userId} report=${reportId}`
)
// Optionally alert or mark for manual review
}
2. Deduplicate Stripe webhooks before INSERT
The second fix was to stop creating those 71 duplicate rows in the first place. Stripe recommends idempotency based on event or session identifiers; I used session.id as the unique key for a payment row.
So before inserting, I now check if a row already exists:
// Inside verify-payment or webhook handler
const { data: existing, error: existingError } = await supabase
.from('report_payments')
.select('id')
.eq('session_id', session.id)
.maybeSingle()
if (existingError) {
throw existingError
}
if (existing) {
// We've already stored this session, nothing to do
return { success: true }
}
// Only insert if no row exists for this session
const { error: insertError } = await supabase
.from('report_payments')
.insert({
user_id: session.metadata.user_id,
report_id: session.metadata.report_id,
session_id: session.id,
amount: session.amount_total,
currency: session.currency,
})
if (insertError) {
throw insertError
}
return { success: true }
Here .maybeSingle() is safe because the uniqueness is based on session_id, and you should also enforce that at the database level with a unique index.
For example:
CREATE UNIQUE INDEX unique_report_payment_session
ON report_payments (session_id);
Now even if the webhook handler runs multiple times, the database itself prevents duplicates and your code exits early.
When should you still use maybeSingle()?
After this bug, I didn't ban .maybeSingle() across the codebase. It's still useful, but I now limit it to very specific cases:
- The table has a database-level unique constraint that matches your query filter (for example, unique email per user).
- You always check
errorand log or handle any unexpected state. - The cost of an ambiguous
nullis low (e.g. optional profile details, not payments or permissions).
A safer pattern when you keep using .maybeSingle() looks like this:
const { data, error } = await supabase
.from('profiles')
.select('*')
.eq('user_id', userId)
.maybeSingle()
if (error) {
// Could be "multiple rows found" or other PostgREST error
console.error('Unexpected profiles result', error)
throw new Error('Ambiguous profile state')
}
if (!data) {
// Truly no profile
return null
}
return data
In other words, treat .maybeSingle() as a convenience for presentation, not as your only guardrail against bad data.
A quick note on ReadyToRelease
This bug actually surfaced while building ReadyToRelease, a tiny SaaS I'm shipping to run market research using AI for indie projects (Next.js, Supabase, Stripe, Groq). The entire 402 vs "user has really paid" mess came from a single unsafe assumption around Supabase's .maybeSingle() helper, not from Stripe or Supabase themselves.
If you're wiring payments, subscriptions, or licensing for your own tools, double-check every .maybeSingle() you've sprinkled around those code paths. The helper does what the docs say: but your data might not.
The rule of thumb for supabase maybeSingle multiple rows
If you're using supabase maybeSingle multiple rows become a possibility the moment your data loses its uniqueness guarantee. Treat null as "zero or ambiguous", not "definitely zero", and use .limit(1) plus explicit length checks when money or security is involved.
Have you already audited where you use .maybeSingle() in your auth or billing logic?
This post is part of the ReadyToRelease engineering blog. If you're building a SaaS, you might also find useful how to validate your idea with real market data before writing production code.
Found this helpful? Share it:
Preguntas Frecuentes
Why does Supabase maybeSingle() return null with multiple rows?▼
When more than one row matches your query, maybeSingle() cannot return a single result and returns null instead of throwing an error. This means null doesn't always mean "no rows", it can mean "ambiguous result". Always check the error object and use .limit(1) with explicit length checks in critical code paths.
How do I fix maybeSingle() returning null unexpectedly in Supabase?▼
Replace .maybeSingle() with .limit(1) and check the array length explicitly. If payments.length === 0 there are no rows; if length >= 1 at least one row exists. This avoids silent failures caused by duplicate data.
Is Supabase maybeSingle() safe to use in payment logic?▼
No. In payment or auth flows, null from maybeSingle() is dangerous because it can mean "zero rows" or "more than one row". Use .limit(1) plus a database-level unique constraint to guarantee safe behavior.
What happens when a query returns zero or multiple rows using single() or maybeSingle()?▼
single() throws an error if zero or more than one row matches. maybeSingle() returns null for zero rows and may return null or an error for multiple rows depending on version. Neither is safe without a database-level unique constraint enforcing the "at most one row" invariant.
Tags:
Ready to launch your SaaS idea?
Get comprehensive market research and competitor analysis in minutes. Skip weeks of manual research and start building faster.
Want more market research?
Get our latest reports delivered to your inbox
Related Articles
TAM SAM SOM for SaaS: How to Calculate Market Size Before You Build
TAM SAM SOM explained with real SaaS examples. Free calculator included. Know your market size before writing a single line of code.
Read moreSupabase Egress Optimization: From 19 GB to Near Zero
Supabase egress spiked to 19 GB from a single .select('*') query. Here's how to detect it, fix it with column selection and pagination, and prevent it in production.
Read moreHow to Validate a SaaS Idea with Market Data
Learn how to validate a SaaS idea with market data: search volume, competitors, intent. Avoid flops—steps + tools for indie hackers
Read more