The problem
You query Supabase with .maybeSingle(), get null back, and assume the row doesn't exist. It does. There are actually three of them. Your app just silently moved on.
Technical context
When building ReadyToRelease, I had a query that checked whether a user already had an active research session before creating a new one. Classic "upsert-like" logic: if it exists, return it; if not, create it.
I trusted .maybeSingle() to handle the "maybe it's there, maybe it's not" case cleanly. It does, but only if your data is clean. The moment you have duplicate rows matching your filter, .maybeSingle() doesn't throw. It doesn't warn. It returns null, exactly like it would if nothing matched.
This is documented behavior. But it's the kind of thing you only truly understand after it burns you in production.
The broken code
const { data, error } = await supabase
.from('research_sessions')
.select('*')
.eq('user_id', userId)
.eq('status', 'active')
.maybeSingle()
if (!data) {
// Assumes: no active session exists → create one
await createNewSession(userId)
}
This looks reasonable. But if the user somehow ended up with two active rows (a race condition, a bad migration, a test script you forgot to clean up), data comes back as null, and you create another session on top of the existing duplicates.
No error. No warning. Just null and a silent cascade.
What's actually happening under the hood
.maybeSingle() is designed to return:
- The row, if exactly one matches
-
null, if zero match -
null+ an error, if more than one match but only in some versions and configurations
The catch: in Supabase JS v2, the behavior when multiple rows match changed subtly. Instead of always surfacing a PGRST116 error, under certain query patterns it silently collapses to null. If you're not explicitly checking error and validating that null actually means "not found", you're flying blind.
The fix
Two layers of defense:
1. Always check the error, even when data is null:
const { data, error } = await supabase
.from('research_sessions')
.select('*')
.eq('user_id', userId)
.eq('status', 'active')
.maybeSingle()
if (error) {
// Could be PGRST116, multiple rows found
console.error('Unexpected query result:', error.message)
throw new Error('Ambiguous session state, manual review needed')
}
if (!data) {
await createNewSession(userId)
}
2. If you need true single-row safety, use a count check first:
const { count, error: countError } = await supabase
.from('research_sessions')
.select('*', { count: 'exact', head: true })
.eq('user_id', userId)
.eq('status', 'active')
if (countError || count !== 1) {
throw new Error(`Expected 1 active session, found ${count}`)
}
const { data } = await supabase
.from('research_sessions')
.select('*')
.eq('user_id', userId)
.eq('status', 'active')
.single() // safe now
Yes, it's two queries. For critical paths, it's worth it.
The general rule
nullfrom.maybeSingle()means "zero or ambiguous", not "definitely zero".
Treat it like you'd treat an HTTP 200 with an empty body: don't assume it means what you think it means without checking everything around it. Always inspect error. Always add a database-level unique constraint if business logic requires exactly one row per user/status combination.
-- The real fix lives here
CREATE UNIQUE INDEX one_active_session_per_user
ON research_sessions (user_id)
WHERE status = 'active';

That index makes the problem impossible at the data layer, which is where it belongs.
Conclusion
.maybeSingle() is not broken, your assumption about what null means is. Add the constraint, check the error, and never let your application logic be the only thing enforcing uniqueness.
Top comments (0)