Fix: Failed to delete user (Supabase)
Failed to delete user: Database error deleting user almost always means some table has a foreign key to auth.users that was created without ON DELETE CASCADE, so Postgres refuses to orphan the child rows. Fix the constraint — references auth.users (id) on delete cascade — rather than working around the delete, and check that nothing was left behind afterwards.
Supabase's own troubleshooting page for user-management errors lists this string alongside its siblings and names three causes: a trigger or function on auth.users, an unmet table constraint, and Prisma having broken permissions on auth.users. In practice the second one accounts for the overwhelming majority.
This is the mirror image of Database error saving new user. That one is a trigger failing on insert; this one is a constraint failing on delete. Same generic wrapper, opposite end of the lifecycle.
The actual Postgres error
The dashboard shows you the wrapper. The real message is in Logs → Postgres, and it looks like this:
update or delete on table "users" violates foreign key constraint "profiles_id_fkey" on table "profiles"
SQLSTATE 23503. Supabase issue #516 is titled almost verbatim — "Cannot delete user in Authentication panel, violates foreign key constraint users_id_fkey" — and #30879, "Failed to delete user: Database error deleting user", is the same complaint without the log line attached.
The constraint name tells you which table is blocking you. profiles_id_fkey on profiles means your profiles table references auth.users and nobody told Postgres what to do with the profile when the user goes away. Postgres's default is NO ACTION: refuse.
Why does Supabase say "Database error deleting user"?
Because deleting an auth user is a normal DELETE against auth.users, and every foreign key pointing at that row gets a vote. Any one of them can veto.
Find all of them:
select
tc.constraint_name,
tc.table_schema || '.' || tc.table_name as child_table,
kcu.column_name,
rc.delete_rule
from information_schema.table_constraints tc
join information_schema.key_column_usage kcu
on kcu.constraint_name = tc.constraint_name
join information_schema.constraint_column_usage ccu
on ccu.constraint_name = tc.constraint_name
join information_schema.referential_constraints rc
on rc.constraint_name = tc.constraint_name
where tc.constraint_type = 'FOREIGN KEY'
and ccu.table_schema = 'auth'
and ccu.table_name = 'users'
order by rc.delete_rule, child_table;
Everything in that result with delete_rule = 'NO ACTION' is a potential blocker. Fix each one deliberately — the right rule is different per table.
Choosing the right delete rule
ON DELETE CASCADE — the child cannot exist without the user. Profiles, sessions, drafts, personal settings, notification preferences. This is the correct default for most tables in a Supabase app, and it's what Supabase's own quickstarts use for profiles.
alter table public.profiles
drop constraint profiles_id_fkey;
alter table public.profiles
add constraint profiles_id_fkey
foreign key (id) references auth.users (id) on delete cascade;
ON DELETE SET NULL — the row must survive the user. Ledger entries, audit logs, invoices you're legally required to keep, messages in a shared thread. Legitimate, but it leaves you with rows whose owner column is now NULL, and that has consequences you have to handle explicitly.
ON DELETE RESTRICT — deletion should be blocked until something else happens first. Rare, but honest: it turns a confusing 500 into a deliberate business rule.
There is no universally right answer, which is why Postgres refuses to guess.
The two things SET NULL breaks
Reaching for SET NULL everywhere is the fast way to make the error go away, and it's what the migration in the sample does:
- Warningsupabase/migrations/20260907120000_user_deletion.sql:10
Table created without enabling RLS
Add ALTER TABLE <table> ENABLE ROW LEVEL SECURITY; plus access policies right after the CREATE TABLE.
The scanner flags the archive table created without RLS, and that's the first problem: you've built a table that holds emails and a jsonb blob of a deleted user's data, exposed through the Data API, with no policy in front of it. If it carries a grant to anon or authenticated, it is readable. A table full of deleted users' personal data is close to the worst possible thing to leave without RLS.
The second problem is subtler and it's about the NULLs themselves.
A well-written policy is safe with them. using (auth.uid() = user_id) evaluates to NULL when user_id is NULL, and a policy predicate that isn't true denies the row — so orphans are hidden from everyone, correctly.
The dangerous shape is the one people write to support "system-owned" or "public" rows:
-- Looks reasonable. Now that orphans exist, it exposes every one of them.
create policy "users see their own and public documents"
on public.documents for select
to authenticated
using (user_id is null or user_id = auth.uid());
Before you changed the constraint, user_id is null meant "a row seeded by us, intentionally public." After, it also means "a row that belonged to someone who deleted their account." Every authenticated user can now read all of it. If you have any policy that treats a null owner as permissive, SET NULL silently changes what it grants — worth auditing alongside the other ways an owner column stops scoping a policy.
And the compliance angle is real regardless of policies: a user asked to be deleted, the auth record is gone, and their documents, messages and metadata are still sitting in your database. "We deleted the login" is not erasure.
The fix that actually works
-- 1. Cascade the tables whose rows genuinely belong to the user.
alter table public.profiles
drop constraint profiles_id_fkey,
add constraint profiles_id_fkey
foreign key (id) references auth.users (id) on delete cascade;
-- 2. For rows that must survive, be explicit about what's kept,
-- and strip the personal data rather than just nulling the owner.
create or replace function public.anonymize_user_records()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
update public.invoices
set billing_email = null,
billing_name = 'Deleted user'
where user_id = old.id;
return old;
end;
$$;
create trigger anonymize_before_user_delete
before delete on auth.users
for each row execute function public.anonymize_user_records();
Two notes on that trigger. security definer with a pinned search_path is what lets it write to public from a delete on auth.users — the same requirement as the signup trigger, and for the same reason. Leaving search_path unpinned on a SECURITY DEFINER function is its own vulnerability.
If your delete still fails after all constraints are cascading, you're on Supabase's third cause: an ORM has broken the grants on auth.users. That's the same class of problem as permission denied for schema public — check whether a Prisma or Drizzle migration touched the auth schema.
Quick self-check
-- Orphans: rows whose owner column is null. Run per table after any
-- SET NULL constraint change.
select 'documents' as tbl, count(*) from public.documents where user_id is null
union all
select 'messages', count(*) from public.messages where user_id is null;
-- Policies that treat a null owner as permissive. These are the ones
-- that change meaning the moment orphans exist.
select schemaname, tablename, policyname, qual
from pg_policies
where schemaname = 'public'
and qual ilike '%is null%';
If the second query returns anything, read each policy carefully before you cascade or null anything else.
FAQ
Is Failed to delete user the same as Database error saving new user?
No. Same generic wrapper, opposite operation. Saving fails on an insert trigger; deleting fails on a foreign key constraint or a delete trigger.
Can I just delete the row from auth.users in the SQL editor?
It hits exactly the same constraints. The SQL editor will at least show you the real 23503 message, which is genuinely useful — but it isn't a bypass.
Will ON DELETE CASCADE on profiles delete the user's storage objects too?
No. Storage objects live in the storage schema with their own ownership. Delete them explicitly, or you'll leave files behind that are still reachable through any signed URL you handed out earlier.
Should I use soft deletes instead?
It's a valid pattern, but it doesn't avoid this decision — it moves it. A soft-deleted user still has an auth.users row, so every policy that scopes on auth.uid() still grants them access unless you explicitly exclude them.
Why does deleting work in the dashboard but not from my app?
The dashboard uses the Admin API as service_role. If your app's delete fails and the dashboard's succeeds, you have a grant or permission problem on auth.users, not a constraint problem.
Catch this before it ships — free
GuardLayer scans every push for this and 33 other Next.js + Supabase issues, with the exact fix inline.
No signup, no card — your code is scanned in memory and never stored.