Skip to content

Security basics

Supabase "duplicate key value violates unique constraint", explained

Supabase "duplicate key value violates unique constraint" means the value is already taken. Find the column, pick a fix, and keep the value off screen.

Vlad Tkachenko12 min read
A clipboard list of names with one line lit in teal, and a slip in front of it carrying the same name in amber, held back from the list.

In short

  • Supabase "duplicate key value violates unique constraint" (Postgres code 23505, HTTP 409 in your app) means a row already holds that value in a column that allows each value once. The name in quotes says which column.
  • "Key (id)=(1) already exists" when your app sends no id means the table's id counter is behind its rows. One setval line puts it right.
  • Upsert only helps when onConflict names that column, and from the browser it needs the table's read rules too. Show your users a sentence of your own: the raw message can carry the value back.

Someone fills in your waitlist form, waits, and presses the button again. The first press had gone through. The second one comes back as an error, and the browser console has all of it:

{
  "code": "23505",
  "details": "Key (email)=(maria@example.com) already exists.",
  "hint": null,
  "message": "duplicate key value violates unique constraint \"waitlist_email_key\""
}

Supabase "duplicate key value violates unique constraint" means Postgres refused to add the row because another row already holds the same value in a column that allows each value once. Here the column is email, and Maria is already on your list.

The advice passed around for this error is to switch the insert to an upsert. Sometimes that is right. On a table your visitors may only add to, though, an upsert from the browser needs rules that let them read the table, and the line with Maria's address in it is something your app should never print to a visitor at all.

What does "duplicate key value violates unique constraint" mean?

It means the value you tried to save is already in a column that takes each value once, so Postgres refused the whole row. Nothing was written, and the row that was there first is unchanged.

Think of the table as the guest list at a door, one line per name. A unique constraint is the doorman's rule that a name goes on the list once. Your app walked up with a name already written there, and he turned it away.

Each part of the error says one thing:

FieldExampleWhat it tells you
code23505Postgres's code for this error, named unique_violation in its list of codes
messageduplicate key value violates unique constraint "waitlist_email_key"Which rule refused the row, by name
detailsKey (email)=(maria@example.com) already exists.The column and the value that clashed, when Postgres decides the caller may see them
hintnullNothing to add for this error
HTTP status409 ConflictHow PostgREST, the part of Supabase that turns supabase.from(...) calls into SQL, reports 23505 (its code)

The wording is Postgres's own. The code that raises it adds the Key ... already exists line only when it has a description of the key to give, and a section further down covers when it has none.

Which column caused it?

The name in quotes tells you, because Postgres builds it from the table and the columns unless somebody chose a different one. Its naming code gives three shapes:

The name ends inIt isExample
_keya unique constraint on the columns between the table name and _keywaitlist_email_key: table waitlist, column email
_pkeythe table's primary key, usually idorders_pkey
_idx, or anything elsea unique index made by hand, or a constraint somebody namedprofiles_username_idx

The message says "unique constraint" for all three. To see exactly what a name covers, run this in the SQL Editor with the name from your message:

select tablename, indexdef
from pg_indexes
where indexname = 'waitlist_email_key';

indexdef comes back as something like CREATE UNIQUE INDEX waitlist_email_key ON public.waitlist USING btree (email), and the columns in the brackets are the ones that may not repeat. A rule on two columns, such as (team_id, email), lets the same address join two teams and refuses it twice in one.

"Key (id)=(1) already exists" when you never sent an id

This one means the table's id counter is behind the rows already in it, so it handed out a number that is taken. Your app sent no id. Postgres took the next number from the counter, and a row with that number was already there.

On the guest list, the doorman also hands out numbered wristbands from a roll. Someone copied the first two hundred guests in by hand, wristband numbers included, and nobody wound the roll on. It still starts at 1.

A column that numbers itself takes each number from a sequence, Postgres's word for that counter. Postgres's page on identity columns says such a column "does not guarantee uniqueness", and gives this case as one of the reasons: values "could be inserted manually into the identity column". Rows arrive with their numbers already filled in from a CSV import that includes the id column, a copy from another project, or a seed script. The counter never hears about any of them. Supabase's troubleshooting page on id sequences covers this exact message on a _pkey, and its repair is a setval as well.

The repair is one statement. Put your own table and column in place of orders and id; the table is the part of the name before _pkey:

select setval(
  pg_get_serial_sequence('public.orders', 'id'),
  (select max(id) from public.orders)
);

pg_get_serial_sequence finds the counter behind the column, and Postgres's documentation says it works for both kinds: "If the column is an identity column, the associated sequence is the sequence internally created for that column." Then setval with two arguments sets the counter so that "the next nextval will advance the sequence before returning a value". The next new row gets the highest id plus one. If Postgres answers that the relation does not exist, the table is spelled differently, has a capital letter or sits in another schema, and that error has a post of its own.

A restore from pg_dump output does not leave you here. Its documentation lists what a data dump carries: "Table data, large objects, and sequence values are dumped." Each counter comes back with its rows.

Rows copied in with their own ids leave the counter where it was. setval moves it past the highest id, and the next row gets a free number.

The three ways to handle a duplicate

Decide what should happen when the value is already there: refuse the new row and say so, let it replace the old row, or stop sending it in the first place.

Refuse it, in your own words. This fits when a second row would be a mistake: an address already registered, a username already taken. The insert fails, your app reads the code, and the person sees a sentence you wrote:

const { error } = await supabase.from('profiles').insert({ id: user.id, username })

if (error?.code === '23505') {
  setMessage('That username is taken. Try another one.')
} else if (error) {
  setMessage('Something went wrong. Please try again.')
}

Let it replace the old row. This fits when the newest version should win: a settings row per user, one cart per session. That is an upsert, and the next section is about when it goes wrong.

Stop sending it. Sometimes the second row was never meant to exist, and it arrives from one of these:

  • a button that sends the form again when pressed again. Disable it until the first request answers.
  • a retry after a slow response, when the first attempt had gone through
  • two writers for one row, such as a trigger that creates each new user's profile and app code that inserts the same profile after sign-up

The trigger case is worth knowing by sight. A trigger that copies new users into a table of yours fails with this error when the value it copies is already there, and Supabase reports the whole sign-up as "Database error saving new user". The 23505 itself is in the Postgres logs, and that post shows where.

Should you use upsert?

Use it when the new row should replace the old one, and check three things before you rely on it.

.upsert() in supabase-js is an insert that does something else when the row exists. Its source puts it in two lines: "If true, duplicate rows are ignored. If false, duplicate rows are merged with existing rows." That is the ignoreDuplicates option, and Postgres runs either version as insert ... on conflict.

1. onConflict has to name the column from your error.PostgREST's documentation says: "By default, upsert operates based on the primary key columns". An upsert of { email } into a table whose primary key is id looks for a clash on id, finds none because the id is new, inserts, and meets the rule on email with the same 23505. Postgres's reference for INSERT says the alternative action is taken "if an arbiter constraint or index specified by conflict_target is violated". A clash on any other column refuses the row as before.

await supabase
  .from('settings')
  .upsert({ user_id: user.id, theme: 'dark' }, { onConflict: 'user_id' })

2. That column needs a unique rule of its own. Without one, Postgres has nothing to match and answers there is no unique or exclusion constraint matching the ON CONFLICT specification, code 42P10 (the line that raises it).

3. From the browser, Row Level Security reads more of your rules. Row Level Security is the set of per-row rules Supabase checks on every request made with your public key. For an upsert, Postgres's reference for policies says "SELECT permissions are required on the relation, and the rows proposed for insertion are checked using the relation's SELECT policies." When the row exists and the upsert merges, the existing row is checked against the table's update rules as well. A row that fails either check ends the request with new row violates row-level security policy.

So on the waitlist, an upsert from the browser fails until the table has a read rule a visitor passes. A read rule a stranger passes for any address is a rule that lets strangers read your list. At the door, writing over a line means being shown the list first. For a table like that, refuse the duplicate instead, or use the function in the next section.

From the browser, an upsert is checked against the table's read and update rules as well as its insert rule. On a table visitors may only add to, the read rule is the one that stops it.

Is it safe to show this error to your users?

No. Show a sentence of your own and keep the raw error in your logs, for two reasons: the detail line can carry the value back, and the error confirms the value exists whatever you print.

The detail line repeats the value. Key (email)=(maria@example.com) is harmless on Maria's own screen and very different in a screenshot posted to a support forum, in an error tracker, or on the screen of whoever typed someone else's address into your form. Postgres already guards it. The function that builds the line (BuildIndexValueDescription) checks first: "if RLS is enabled for the relation. If so, return NULL to avoid leaking data." A request made with your public key to a table with Row Level Security on gets "details": null.

Read the other way round, the line tells you about the table. If your browser console shows Key (...)=(...) for a request your app made with the public key, Row Level Security is off on that table. The one exception is a request your app sends through server code holding the secret key, which skips the rules entirely. null proves less: Postgres also drops the line when the caller cannot read every column in the key. Turning Row Level Security on for every table covers the switch and the first rule.

A table with the rules off answers a stranger as readily as it answered you. Our free scan reads your live app with the same public key a visitor gets, reports which tables hand back rows, and needs no account.

The error answers a question you may not want answered. With the detail line gone, a 409 still tells whoever sent the row that the value is already there. Postgres's page on row security says unique constraints "always bypass row security to ensure that data integrity is maintained", and asks you to design tables and rules so these checks do not turn into a covert channel. On the waitlist, anyone can learn whether an address is on your list by trying to add it. The doorman never reads the name out, and turning someone away still tells the queue it was there.

If that matters for your list, give the browser the same answer either way. A function in the database can add the address, skip it if it is already there, and return nothing in both cases:

create function public.join_waitlist(new_email text)
returns void
language sql
security definer
set search_path = ''
as $$
  insert into public.waitlist (email)
  values (new_email)
  on conflict (email) do nothing;
$$;

Your app calls supabase.rpc('join_waitlist', { new_email: email }). security definer runs the insert as the function's owner, so visitors need no rule on the table at all, and the rule that let them insert can go, or the old route stays open beside the new one. What "Database error saving new user" says about security definer explains what it hands over, and calling a Supabase function covers matching the argument names.

Postgres writes the value into the detail line only for a caller Row Level Security does not apply to. Seeing it from the browser means the table's rules are off.

Before you change how your app writes

Keep a copy of the database from before you switch an insert to an upsert or reset a counter, and hear about it when a table starts answering strangers.

Reeve Care keeps a copy of your Supabase database:

  • an encrypted copy on a schedule, on Reeve's own storage in the EU, outside your Supabase account
  • every table with its rows as they were before a merge wrote over them
  • each table's id counter put back with its rows on a restore, so the next new row gets a free number
  • each copy verified before it counts, by counting the rows in every table
  • your uploaded files as well, once you connect a Storage credential
  • a one-click restore, with a safety copy of the current state taken first
  • everything Monitor does

Reeve Monitor keeps checking from outside:

  • whether your app is up, checked every minute
  • the full security check every hour, on up to three apps
  • a message when a result changes, so a table that starts answering strangers after a deploy reaches you without you going to look
  • a monthly report, in plain language, of what it saw

What to do now

What to do

  • Read the name in quotes. The table and column sit before _key, and _pkey means the primary key.
  • If the name ends in _pkey and your app sends no id, move the counter with the setval statement above.
  • Catch 23505 in your app and show a sentence you wrote, never the raw message.
  • Use upsert only where the new row should replace the old one, with onConflict set to the column in the error.
  • If the browser console shows a Key (...)=(...) line for a request made with your public key, switch Row Level Security on for that table.

Start with the name in quotes, because it says which rule refused the row and which column that rule guards. Before you run a setval, an upsert or anything else that rewrites rows people depend on, keep a copy of the database from before the change.

FAQ

What does duplicate key value violates unique constraint mean?

It means Postgres refused to add or change a row because another row already holds the same value in a column, or set of columns, that allows each value only once. Nothing was written. The code is 23505, and from a Supabase app it arrives as HTTP 409.

Which column caused it?

The constraint name in quotes tells you. Postgres names a unique constraint after its table and columns with _key at the end, so waitlist_email_key is the email column of the waitlist table, and a name ending in _pkey is the primary key, usually id. The detail line, when it is there, names the column and the value.

Should I use upsert?

Only when the new row should replace the old one, such as a settings row per user. Set onConflict to the column in the error, because Supabase upserts check the primary key by default. From the browser, an upsert also needs the table to have read rules the row passes, so on a table visitors may only add to it fails or forces you to open the table for reading.

Why does this happen on signup?

Two ways lead there: a trigger copies each new user into a table of yours and the value it copies is already there, or your app inserts the same profile row the trigger already made. Supabase reports either as "Database error saving new user", and the 23505 itself is in the Postgres logs.

Is it safe to show this error to users?

No. The detail line can repeat the value back, the message names your table and column, and the error itself tells whoever sent the row that the value exists. Catch code 23505, show a sentence you wrote, and keep the raw error in your own logs.

Written by

Vlad Tkachenko

Founder, Reeve

I spend my time looking at apps built with Lovable, Bolt, v0, Cursor and Replit, and at the short list of mistakes that keep turning up in them.

More about the author

Read next

All articles

Back up your Supabase database before your next change

Reeve keeps an encrypted copy on a schedule and restores it for you if something breaks.

See Supabase backups

Automated external check, not a full audit. Absence of findings is not a guarantee of safety.