DEV Community

Cover image for Supabase .maybeSingle() returns null with multiple rows, and it won't tell you why
Victor Caña
Victor Caña

Posted on

Supabase .maybeSingle() returns null with multiple rows, and it won't tell you why

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Yes, it's two queries. For critical paths, it's worth it.


The general rule

null from .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';
![ ](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cypnd64lif2ughooz9zf.png)


Enter fullscreen mode Exit fullscreen mode

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)