Supabase .maybeSingle() returns null with multiple rows, and your payments logic quietly breaks
Supabase .maybeSingle() can return null when multiple rows match. That silent behavior broke my Stripe payments flow. Here’s what happened, why, and how to fix it safely
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.
1. 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?
Found this helpful? Share it:
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.
Related Articles
The SaaS Launch Playbook: 7 Proven Strategies to Get Your First 100 Paying Customers
Master the SaaS launch process. Learn 7 battle-tested strategies to acquire your first 100 paying customers without a massive marketing budget
Read moreHow to Validate Your SaaS Idea Before Wasting 6 Months Building the Wrong Product
Discover the proven framework to validate your SaaS idea in weeks, not months. Learn how to test assumptions, talk to customers, and avoid costly mistakes before launch
Read moreHow Can Founders Validate a Startup Idea Using Market Data?
Learn how to validate your startup idea with real data using ReadyToRelease — the €9.99 tool that automates market-research with data from Product Hunt, Census, World Bank, Reddit, and Gemini
Read more