Home / RLS / Update or delete does nothing (no error)

Supabase RLS · Error fix

Supabase update or delete does nothing, and there's no error

Postgres 15 · Supabase✓ code tested against a real database
TL;DR

With RLS on, an UPDATE or DELETE only touches rows a policy lets you act on. No matching policy means 0 rows affected and no error. Add an UPDATE policy (with using and with check) and a DELETE policy, and keep a SELECT policy: UPDATE needs one too.

01 Why it's silent

RLS filters rows, it doesn't reject statements. An update ... where id = 1 that no policy allows simply matches nothing, so Postgres reports UPDATE 0 and supabase-js returns error: null. Inserts are different: a blocked insert raises new row violates row-level security policy.

Make the silence visible by asking for the affected rows back:

debug.ts

const { data, error } = await supabase
  .from('notes').update({ body }).eq('id', id).select();
console.log(data);   // [] → RLS let you touch 0 rows

02 The usual causes

CAUSE 1 · most common

Only a SELECT policy exists

RLS is default-deny per operation. A read policy doesn't allow updates or deletes.

CAUSE 2

An UPDATE policy but no SELECT policy

To update a row Postgres must first find it, and that read goes through your SELECT policies. With no SELECT policy for the role, the UPDATE matches 0 rows.

CAUSE 3

No session

Called as anon, auth.uid() is null, so an owner policy matches nothing.

03 The fix

migration.sql

create policy "owner reads" on notes for select to authenticated
  using (user_id = (select auth.uid()));

create policy "owner updates" on notes for update to authenticated
  using (user_id = (select auth.uid()))        -- which rows you may change
  with check (user_id = (select auth.uid()));  -- what they may become

create policy "owner deletes" on notes for delete to authenticated
  using (user_id = (select auth.uid()));

grant select, update, delete on notes to authenticated;

The with check on UPDATE matters: without it, a user could change user_id and hand their row to someone else.

04 Prove it

✗ only a SELECT policy, A updates own row  → 0 rows, no error
✓ with UPDATE policy, A updates own row  → 1 row
✓ A updates B's row  → 0 rows
✓ A sets user_id to B  → blocked by WITH CHECK
✓ with DELETE policy, A deletes own row  → 1 row
◆ FREE · MIT · npm + GitHub Action

Catch this before it ships

airlock-rls is a CI gate that fails your build when a table ships exposed or a policy is permissive: the same class of bug, caught in CI against a database with your migrations applied, before it reaches prod.

npx airlock-rls→

Or start from nextjs-supabase-starter : auth + a table with RLS + an isolation test, so you start from a pattern that's already sealed.

FREE PDF

Grab the Supabase RLS cheat sheet

The golden rules, the footguns that leak in prod, correct policy snippets, and the isolation test — on one page.

FAQ

Why does the update work in the SQL editor?

The SQL editor bypasses RLS. Your app runs as authenticated or anon, where policies apply. Test writes the way the app makes them.

Can I get an error instead of 0 rows?

Not from RLS itself. Chain .select() after update() or delete() and treat an empty array as a failure in your code.