TLDR: Supabase's batch upsert silently turns missing keys into NULLs — which bypasses your schema defaults and can quietly discard your data. error === null does NOT mean it worked.

the setup

We were building a webinar analytics dashboard — a live analytics tool for webinar performance: attendance curves, conversion funnels, sales velocity, the whole picture.

One of the key features is a sales update flow: after a webinar runs, someone uploads the purchase data and the dashboard re-renders with real numbers.

It was working. Or so we thought.

the wall we hit

The sales update would POST, no error thrown, UI said success — and the dashboard still showed stale data.

Not wrong data. Old data.

I figured it had to be caching. Next.js RSC cache is famously aggressive. So I switched to a full page reload after the update. Still stale.

Then I went hunting for a timezone offset problem (the viewer timeline labels were using the wrong reference point for contentStart). Fixed that too. Still stale.

I added diagnostics. Logged the entire update payload to the response. Looked correct going in.

So what was happening on the other end?

the real culprit

Supabase's batch upsert has a footgun I hadn't internalized yet.

When you send a heterogeneous array — rows where some have a key and others don't — supabase-js / PostgREST normalizes the whole batch to a uniform column shape before it hits Postgres. Any key present on any row gets added to all rows. The rows that didn't have it? They get NULL.

That sounds fine until you realize: explicit NULL is not the same as an omitted column.

Your schema default only fires for omitted columns. If the column is present in the INSERT shape with a NULL value, the default doesn't run. And if that column has a NOT NULL constraint or a CHECK — the write can silently fail entirely, with error === null on the JS side.

No error. No warning. Just… nothing changed.

the fix that worked

The commit says it plainly: use explicit createMany for sales update to prevent silent data loss.

Instead of letting the batch upsert normalize the shape, I built every row fully and explicitly before it hit the wire. Every required column set in JavaScript, no skipped keys, no relying on Postgres defaults to catch the gaps.

The sales data started landing immediately.

why this matters

The dangerous part isn't that Supabase has this behavior — PostgREST has to normalize batch shapes somehow. The dangerous part is that it fails completely silently.

error === null in supabase-js means the request was received. It does not mean your data was written.

The defensive pattern I now follow for any batch write:

  • Normalize every required column in JS before the array hits supabase-js. Don't let the DB default be your safety net.
  • Chain .select() after updates and verify the returned row reflects what you intended.
  • If correctness matters more than ergonomics — reach for raw fetch.

I spent a good couple hours chasing the cache and the timezone before I looked at the actual write path. That's on me. Now I check the write first.

P.S. This pairs nastily with the PostgREST 1000-row default truncation — another silent data loss vector on reads. Two separate places Supabase can quietly lie to you. Check both.