Backups
"Database error deleting user" in Supabase, and what goes with it
"Database error deleting user" in Supabase means your database refused the delete. The reason is in your logs, and the fix decides what else is deleted.

In short
- "Database error deleting user" in Supabase means the delete reached your database and was refused, so nothing was removed. The line that names the cause is in your Postgres logs, and Supabase's own troubleshooting note sends you there.
- When that line says a foreign key is violated, a row in one of your tables still holds the user's id. Postgres will not remove a person while something still points at them, unless you told it what to do with those rows.
- The answer you will find first is on delete cascade. It deletes every row that points at the user, and every row that points at those, on every delete from then on. Choose per table, and take a copy before you change one.
You open Authentication → Users in your Supabase dashboard and delete an account. Maybe the person asked you to remove them, maybe it is a test account you made last week. The dashboard answers:
Failed to delete user: Database error deleting user
The account is still in the list. The app you built with Lovable, Bolt, v0, Cursor, Claude Code or Replit has worked all along, and the sentence names no table and no reason.
The answer you will find first is one line of SQL: add on delete cascade to
whatever is in the way. It works. It also decides, for every account deleted
from then on, which other rows are deleted with it, and nothing asks you about
that again. So read the real error first, and then make that decision one table
at a time.
What does "Database error deleting user" mean in Supabase?
Your database refused the delete, and Supabase undid all of it, so the account is exactly as it was.
Deleting a user is a job for Supabase Auth, the service behind your app's
sign-in. In Auth's own source code the delete runs inside one transaction, a
group of changes that either all happen or none do, and its central step removes
the person's row from auth.users. If anything in your database objects to
that, the transaction is rolled back and Auth replies with this one sentence.
Supabase lists it as HTTP 500 with the code unexpected_failure, beside its twin
for signups,
"Database error saving new user".
Think of your database as a members' club. auth.users is the roll of members,
and every member has a number: their user id. Your other books copy that number
onto their lines, whether it is a profile, an order, a post or a comment.
Striking someone off the roll takes one line. Every other book still carries the
number, though, and unless somebody wrote down what to do about those lines, the
club's secretary refuses.
Where is the real error?
In your project's Postgres logs, at the moment the delete failed.
Supabase's troubleshooting note for the user-management errors gives the route. Try the delete once and note the time. Then, in the dashboard:
- Open Logs and read the Auth logs at that time. The entry is Auth's own record of the failed delete.
- Open the Postgres logs at the same time. That line is the real error, and it names a table, a rule or a permission.
The same note lists three causes, and the log line tells you which one you have:
- a trigger on the
auth.userstable that fails - a constraint that is not being met, which on a delete means a foreign key in
another table pointing at
auth.users - Prisma, which the note says can break the permissions on
auth.users
A table that still points at the user
If the Postgres line says violates foreign key constraint, a row in one of your
tables still holds this user's id, and Postgres will not remove a person that
something still points at.
Supabase's note prints the shape it takes:
ERROR: 23503: update or delete on table "users" violates foreign key constraint "profiles_id_fkey" on table "profiles"
DETAIL: Key (id)=(7428a53c-75b7-4531-9ae9-1567d9c4ac0a) is still referenced from table "profiles".
A foreign key is a rule on a column saying that every value in it must exist in
another table. Here the rule is called profiles_id_fkey, it lives on your
profiles table, and the Key line is the id of the user you tried to delete.
In the club, that is a line in the profiles book carrying the member's number,
and the secretary refusing to strike them off while it is there.
The same rule refuses a save from the other side. A new row that names a user
who does not exist comes back as
insert or update on table … violates foreign key constraint, and
that message has a post of its own.
The refusal is the default. A foreign key created with nothing after
references auth.users gets the behaviour Postgres calls no action, and
Supabase's guide to cascade deletes says so in as many words: NO ACTION is the
default behaviour if you do not specify anything. A table your builder created
that way works perfectly until the first time somebody is deleted.
To see every table that points at your users, and what each one does on a delete, run this in the SQL Editor:
select conrelid::regclass as table_name,
conname as rule,
case confdeltype
when 'c' then 'cascade'
when 'n' then 'set null'
when 'd' then 'set default'
when 'r' then 'restrict'
else 'no action'
end as on_delete
from pg_constraint
where contype = 'f'
and confrelid = 'auth.users'::regclass
and connamespace <> 'auth'::regnamespace;
It reads Postgres's own catalogue and changes nothing. The last line leaves out
Supabase's own tables in the auth schema. Each row that says no action or
restrict is a table that will refuse the delete whenever it holds a row for
that user.
What does on delete cascade delete?
Every row that points at the user, then every row that points at those, on every delete from then on.
It is the fix you are handed first, and Supabase's user-management guide uses it
for a profiles table: "Specify on delete cascade in the reference." For a
profile it is right. A profile describes the person and nothing else, so it
should leave when they do.
The trouble starts when the same words go on every table. Supabase's cascade guide describes the behaviour plainly: when a row is deleted from the parent table, all related rows in the child tables are deleted as well. It also carries on down. In that guide's own example, a single delete from the top table empties the two tables beneath it.
In the club, a cascade tells the secretary to tear out every line carrying the member's number, and then every line in any other book that pointed at a torn-out line. Picture a user who wrote posts, with other people's comments pointing at those posts. With cascade on both, deleting that one user removes their posts and everybody else's comments on them.
Nothing tells you what went with it. Auth answers a successful delete with an empty reply, the dashboard shows one account fewer, and the rows that left at the same moment are missing the next time anybody looks for them.
Choosing what each table keeps
Decide per table, because each one holds something different about the person who left.
| What the table holds | on delete | When the user is deleted |
|---|---|---|
| Only the person: a profile, their settings | cascade | The rows go with them |
| Things other people still need: a comment in someone else's thread, a payment record | set null | The rows stay, with the owner column empty |
| Anything you want to deal with by hand first | no action (the default) | The delete is refused while those rows remain |
set null comes with two conditions. The column has to allow empty values, or
the delete fails again with a message of its own,
null value in column … violates not-null constraint. And a policy written as
user_id = auth.uid() will never show that row to anybody again, because in SQL
nothing is equal to an empty value. The row is kept, and your app stops showing
it to anyone.
Changing a rule means dropping it and adding it back with the new behaviour, which is what Supabase's note recommends:
alter table public.profiles
drop constraint profiles_id_fkey,
add constraint profiles_id_fkey
foreign key (id) references auth.users (id) on delete cascade;
Use the rule name the query above printed, with your own table and column.
There is one more way, and it keeps every row. Supabase Auth can soft-delete an
account: auth.admin.deleteUser() takes a shouldSoftDelete option, and with
it Auth keeps the row in auth.users, marks it deleted, scrambles the email and
phone, clears the password and the metadata, and ends the sessions. Nothing was
removed, so no foreign key fires. It runs from a server with your secret key,
never from the browser.
A trigger that cannot reach your table
If the Postgres line says permission denied for table, a trigger on
auth.users ran during the delete and tried to touch a table it is not allowed
into.
The role Supabase Auth works as, supabase_auth_admin, only has the permissions
its own job needs. Supabase's note puts it this way: when a trigger operated by
supabase_auth_admin interacts outside the auth schema, it causes a permission
error. A trigger that tidies up after a deleted account, say by writing to a log
table of your own, fails exactly like that unless its function was created with
security definer.
The repair is the one for signups: a security definer function, created by the
postgres role, doing one narrow thing.
The signup version of this error goes
through it line by line, including the empty search path that keeps it safe.
If your app uses Prisma, read the third cause in Supabase's note before anything
else, because it names Prisma as a way the permissions on auth.users get
broken.
What happens to the files they uploaded?
On a current project the delete goes through, and the files stay where they are.
Supabase's user-management guide still warns that you cannot delete a user who
owns objects in Supabase Storage. That was true while storage.objects, the
table where Storage keeps a row for every file, had a foreign key called
objects_owner_fkey pointing at auth.users. Storage has since dropped that key
in its own schema migrations (0017-drop-owner-foreign-key.sql), and its
ownership guide now says ownership lives in an owner_id column. If the query
earlier in this post lists storage.objects with objects_owner_fkey, your
project still has the old rule and the guide's warning applies to you.
Otherwise every file the person uploaded stays in its bucket after they are
gone. In the club, the coats stay on their hooks with a number tag nobody holds.
A policy written as owner_id = auth.uid()::text will never match them again,
and a file in a public bucket is still served to anybody who has its link.
Whether a stranger can list the files in a bucket of yours is one of the checks
in Reeve's free scan.
To remove them, go through Storage itself, from the dashboard's Storage page or the Storage API. A plain SQL delete on its tables is refused, and Storage says why: "Direct deletion from storage tables is not allowed. Use the Storage API instead." What a database copy holds of those files, and what it leaves out, is in backing up Supabase Storage.
Take a copy before you change a foreign key
Postgres has no recycle bin, and a cascade removes rows from tables you were not looking at.
Whatever a cascade or a set null changes is final the moment the delete
succeeds, and the only way back is a copy taken before it. Supabase's free plan
keeps no backups at all. Pro keeps 7 days of daily copies, Team 14 and Enterprise
up to 30, and each of them lives inside the same Supabase project.
Copying your database yourself
takes a few commands, and
a copy that includes your users needs the
third of the three commands Supabase publishes.
Then try the new rules on a test account before a real one. Create an account, give it one row in each table the query listed, delete it, and look at what is left in each table.
If you would rather that copy already existed before anybody pressed delete, that is what Supabase backups by Reeve are for.
Keeping a way back when a delete takes more than you meant
A cascade reports nothing about what it removed. You find out when somebody asks where their comments went.
Reeve Care keeps a copy of your Supabase database from before the delete ran:
- an encrypted copy on a schedule, kept on Reeve's own storage in the EU, outside your Supabase account
- each copy verified before it counts, by counting the rows in every table
- a one-click restore that saves what is there now before it replaces anything
- your uploaded files as well, once you connect a Storage credential
- everything Reeve Monitor does: all nine checks on your live app every hour, and an uptime check every 60 seconds
What to do today
What to do
- Try the delete once, note the time, and read the Postgres log line at that moment before you change anything.
- If it names a foreign key, run the query above to list every table that points at your users and what each one does on a delete.
- Choose per table:
cascadefor rows that only describe the person,set nullfor rows other people still need, the default for anything you want to clear by hand. - Take a copy of the database before you change any of those rules, and try the new rules on a test account first.
- Remove the person's files through Storage as well. On a current project the account goes and the files stay.
If rows have already gone further than you meant, what you can get back after a delete goes through where to look, in order.
FAQ
What does "Database error deleting user" mean in Supabase?
It means Supabase Auth tried to remove the person's row from auth.users, something in your database refused, and the whole delete was undone. The account is exactly as it was. The message names no table, and the line that does is in your project's Postgres logs at the same moment.
Should I add on delete cascade to fix it?
On a table that only describes the person, like a profiles table, yes, and Supabase's own guide does exactly that. On a table holding things other people still need, like comments or orders, decide first. Cascade deletes every row pointing at the user and every row pointing at those, on every delete from then on, and nothing reports what it removed.
Can I delete a user and keep their rows?
Yes, two ways. Set the foreign key to on delete set null, and the rows stay with an empty owner column, which needs that column to allow empty values. Or call auth.admin.deleteUser() from a server with its shouldSoftDelete option: Supabase Auth keeps the row, marks it deleted, scrambles the email and phone and clears the password, so nothing pointing at it is touched.
Why does Supabase say I cannot delete a user who uploaded files?
Its user-management guide still says so, and it was true while storage.objects had a foreign key called objects_owner_fkey on auth.users. Supabase Storage has since dropped that key in its own schema migrations. On a current project the delete goes through and the files stay in their bucket, under an owner_id no user has any more.
Does deleting a user sign them out?
Not straight away. Supabase's guide says an access token already issued stays valid until it expires, even after the account is deleted. Deleting the user removes their sessions, so the app cannot get a new token, but the one in hand keeps working until its expiry time.