Zum Inhalt springen

Sicherheitsgrundlagen

Supabase "duplicate key value violates unique constraint" erklärt

Supabase "duplicate key value violates unique constraint" heißt: Der Wert ist schon vergeben. Spalte finden, Lösung wählen, Wert vom Bildschirm fernhalten.

Vlad Tkachenko12 Min. Lesezeit
Eine Namensliste auf einem Klemmbrett, eine Zeile in Türkis, davor ein Zettel mit demselben Namen in Bernstein, der draußen bleibt.

Kurz gesagt

  • Supabase "duplicate key value violates unique constraint" (Postgres-Code 23505, HTTP 409 in deiner App) heißt: Eine Zeile hat diesen Wert schon in einer Spalte, die jeden Wert nur einmal erlaubt. Der Name in Anführungszeichen sagt, welche Spalte.
  • "Key (id)=(1) already exists", obwohl deine App keine id schickt, heißt: Der ID-Zähler der Tabelle hinkt ihren Zeilen hinterher. Eine setval-Zeile bringt ihn in Ordnung.
  • Upsert hilft nur, wenn onConflict genau diese Spalte nennt, und aus dem Browser braucht es auch die Leseregeln der Tabelle. Zeig deinen Nutzern einen eigenen Satz: Die rohe Meldung kann den Wert zurückliefern.

Jemand füllt das Formular für deine Warteliste aus, wartet und drückt den Knopf noch einmal. Der erste Klick war schon durchgegangen. Der zweite kommt als Fehler zurück, und die Browserkonsole zeigt alles:

{
  "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" heißt, dass Postgres die Zeile abgelehnt hat, weil eine andere Zeile denselben Wert schon in einer Spalte hat, die jeden Wert nur einmal erlaubt. Hier ist die Spalte email, und Maria steht schon auf deiner Liste.

Der Rat, der für diesen Fehler herumgereicht wird, lautet: das Insert auf ein Upsert umstellen. Manchmal stimmt das. Auf einer Tabelle, in die Besucher nur eintragen dürfen, braucht ein Upsert aus dem Browser allerdings Regeln, die ihnen das Lesen der Tabelle erlauben, und die Zeile mit Marias Adresse sollte deine App einem Besucher überhaupt nie zeigen.

Was bedeutet "duplicate key value violates unique constraint"?

Der Wert, den du speichern wolltest, steht schon in einer Spalte, die jeden Wert nur einmal nimmt, also hat Postgres die ganze Zeile abgelehnt. Geschrieben wurde nichts, und die Zeile, die zuerst da war, ist unverändert.

Stell dir die Tabelle als Gästeliste an einer Tür vor, eine Zeile pro Name. Eine Unique-Regel ist die Regel des Türstehers, dass ein Name nur einmal auf die Liste kommt. Deine App kam mit einem Namen, der schon dasteht, und er hat sie abgewiesen.

Jeder Teil des Fehlers sagt eine Sache:

FeldBeispielWas es dir sagt
code23505Der Code von Postgres für diesen Fehler, unique_violation in seiner Liste der Codes
messageduplicate key value violates unique constraint "waitlist_email_key"Welche Regel die Zeile abgelehnt hat, mit Namen
detailsKey (email)=(maria@example.com) already exists.Spalte und Wert, die kollidiert sind, wenn Postgres entscheidet, dass der Aufrufer sie sehen darf
hintnullNichts hinzuzufügen bei diesem Fehler
HTTP-Status409 ConflictSo meldet PostgREST, der Teil von Supabase, der Aufrufe supabase.from(...) in SQL übersetzt, den 23505 (sein Code)

Der Wortlaut stammt von Postgres selbst. Der Code, der den Fehler auslöst, hängt die Zeile Key ... already exists nur an, wenn er eine Beschreibung des Schlüssels hat, und ein Abschnitt weiter unten zeigt, wann er keine hat.

Welche Spalte hat den Fehler ausgelöst?

Das sagt der Name in Anführungszeichen, denn Postgres baut ihn aus Tabelle und Spalten, solange niemand einen anderen gewählt hat. Sein Code für die Namen kennt drei Formen:

Der Name endet aufEs istBeispiel
_keyeine Unique-Regel auf den Spalten zwischen Tabellenname und _keywaitlist_email_key: Tabelle waitlist, Spalte email
_pkeyder Primärschlüssel der Tabelle, meist idorders_pkey
_idx oder etwas anderesein von Hand angelegter Unique-Index oder eine Regel mit eigenem Namenprofiles_username_idx

Die Meldung sagt bei allen dreien "unique constraint". Um genau zu sehen, was ein Name abdeckt, führ das im SQL Editor aus, mit dem Namen aus deiner Meldung:

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

indexdef kommt etwa als CREATE UNIQUE INDEX waitlist_email_key ON public.waitlist USING btree (email) zurück, und die Spalten in den Klammern sind die, die sich nicht wiederholen dürfen. Eine Regel auf zwei Spalten, etwa (team_id, email), lässt dieselbe Adresse in zwei Teams zu und lehnt sie im selben Team beim zweiten Mal ab.

"Key (id)=(1) already exists", obwohl du nie eine id geschickt hast

Hier hinkt der ID-Zähler der Tabelle den Zeilen hinterher, die schon drin sind, also hat er eine Nummer vergeben, die besetzt ist. Deine App hat keine id geschickt. Postgres hat die nächste Nummer vom Zähler genommen, und eine Zeile mit dieser Nummer gab es schon.

Auf der Gästeliste verteilt der Türsteher auch nummerierte Armbänder von einer Rolle. Jemand hat die ersten zweihundert Gäste von Hand eingetragen, samt Armbandnummern, und niemand hat die Rolle weitergedreht. Sie fängt immer noch bei 1 an.

Eine Spalte, die sich selbst nummeriert, nimmt jede Nummer aus einer Sequenz, so nennt Postgres diesen Zähler. Postgres' Seite über Identity-Spalten sagt, eine solche Spalte "does not guarantee uniqueness", und nennt genau diesen Fall als einen der Gründe: Werte "could be inserted manually into the identity column". Zeilen kommen mit schon ausgefüllten Nummern an, aus einem CSV-Import mit der Spalte id, einer Kopie aus einem anderen Projekt oder einem Seed-Skript. Der Zähler erfährt von keiner davon. Supabases Troubleshooting-Seite über ID-Sequenzen behandelt genau diese Meldung bei einem _pkey, und ihre Lösung ist ebenfalls ein setval.

Die Reparatur ist eine Anweisung. Setz deine eigene Tabelle und Spalte statt orders und id ein; die Tabelle ist der Teil des Namens vor _pkey:

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

pg_get_serial_sequence findet den Zähler hinter der Spalte, und die Dokumentation von Postgres sagt, dass das für beide Arten funktioniert: "If the column is an identity column, the associated sequence is the sequence internally created for that column." Danach stellt setval mit zwei Argumenten den Zähler so, dass "the next nextval will advance the sequence before returning a value". Die nächste neue Zeile bekommt die höchste id plus eins. Antwortet Postgres, dass die Relation nicht existiert, ist die Tabelle anders geschrieben, hat einen Großbuchstaben oder liegt in einem anderen Schema, und dieser Fehler hat einen eigenen Beitrag.

Eine Wiederherstellung aus der Ausgabe von pg_dump bringt dich nicht hierher. Seine Dokumentation zählt auf, was ein Datendump enthält: "Table data, large objects, and sequence values are dumped." Jeder Zähler kommt mit seinen Zeilen zurück.

Zeilen, die mit eigenen ids hineinkopiert wurden, lassen den Zähler, wo er war. setval schiebt ihn über die höchste id hinaus, und die nächste Zeile bekommt eine freie Nummer.

Die drei Wege, mit einem Duplikat umzugehen

Entscheide, was passieren soll, wenn der Wert schon da ist: die neue Zeile ablehnen und das sagen, sie die alte ersetzen lassen oder sie gar nicht erst schicken.

Lehn sie ab, mit eigenen Worten. Das passt, wenn eine zweite Zeile ein Fehler wäre: eine Adresse, die schon registriert ist, ein Nutzername, der schon vergeben ist. Das Insert scheitert, deine App liest den Code, und die Person sieht einen Satz, den du geschrieben hast:

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

if (error?.code === '23505') {
  setMessage('Dieser Nutzername ist vergeben. Versuch einen anderen.')
} else if (error) {
  setMessage('Etwas ist schiefgegangen. Bitte versuch es noch einmal.')
}

Lass sie die alte ersetzen. Das passt, wenn die neueste Fassung gewinnen soll: eine Einstellungszeile pro Nutzer, ein Warenkorb pro Sitzung. Das ist ein Upsert, und der nächste Abschnitt zeigt, wann er schiefgeht.

Schick sie nicht. Manchmal sollte es die zweite Zeile nie geben, und sie kommt aus einer dieser Quellen:

  • ein Knopf, der das Formular noch einmal schickt, wenn man ihn noch einmal drückt. Deaktivier ihn, bis die erste Anfrage antwortet.
  • ein neuer Versuch nach einer langsamen Antwort, obwohl der erste durchgegangen war
  • zwei Schreiber für eine Zeile, etwa ein Trigger, der für jeden neuen Nutzer ein Profil anlegt, und App-Code, der dasselbe Profil nach der Registrierung einfügt

Den Trigger-Fall solltest du auf Anhieb erkennen. Ein Trigger, der neue Nutzer in eine eigene Tabelle kopiert, scheitert mit diesem Fehler, wenn der kopierte Wert dort schon steht, und Supabase meldet die ganze Registrierung als "Database error saving new user". Den 23505 selbst findest du in den Postgres-Logs, und dieser Beitrag zeigt, wo.

Solltest du upsert benutzen?

Benutz es, wenn die neue Zeile die alte ersetzen soll, und prüf drei Dinge, bevor du dich darauf verlässt.

.upsert() in supabase-js ist ein Insert, das etwas anderes tut, wenn die Zeile existiert. Sein Quellcode sagt es in zwei Zeilen: "If true, duplicate rows are ignored. If false, duplicate rows are merged with existing rows." Das ist die Option ignoreDuplicates, und Postgres führt beide Fassungen als insert ... on conflict aus.

1. onConflict muss die Spalte aus deinem Fehler nennen.Die Dokumentation von PostgREST sagt: "By default, upsert operates based on the primary key columns". Ein Upsert von { email } in eine Tabelle mit dem Primärschlüssel id sucht nach einer Kollision bei id, findet keine, weil die id neu ist, fügt ein und trifft auf die Regel für email, mit demselben 23505. Die INSERT-Referenz von Postgres sagt, die Ausweichaktion greift, "if an arbiter constraint or index specified by conflict_target is violated". Eine Kollision in jeder anderen Spalte lehnt die Zeile ab wie vorher.

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

2. Diese Spalte braucht eine eigene Unique-Regel. Ohne sie hat Postgres nichts zum Abgleichen und antwortet there is no unique or exclusion constraint matching the ON CONFLICT specification, Code 42P10 (die Zeile, die das auslöst).

3. Aus dem Browser liest Row Level Security mehr von deinen Regeln. Row Level Security sind die Regeln pro Zeile, die Supabase bei jeder Anfrage mit deinem öffentlichen Schlüssel prüft. Für einen Upsert sagt Postgres' Referenz zu Policies: "SELECT permissions are required on the relation, and the rows proposed for insertion are checked using the relation's SELECT policies." Existiert die Zeile und der Upsert führt zusammen, wird die vorhandene Zeile zusätzlich gegen die Update-Regeln der Tabelle geprüft. Eine Zeile, die eine der beiden Prüfungen nicht besteht, beendet die Anfrage mit new row violates row-level security policy.

Auf der Warteliste scheitert ein Upsert aus dem Browser also, bis die Tabelle eine Leseregel hat, die ein Besucher besteht. Eine Leseregel, die ein Fremder für jede beliebige Adresse besteht, ist eine Regel, die Fremde deine Liste lesen lässt. An der Tür heißt eine Zeile überschreiben: Man bekommt die Liste vorher gezeigt. Bei so einer Tabelle lehn das Duplikat lieber ab, oder nimm die Funktion aus dem nächsten Abschnitt.

Aus dem Browser wird ein Upsert gegen die Lese- und Update-Regeln der Tabelle geprüft, nicht nur gegen die Insert-Regel. Auf einer Tabelle, in die Besucher nur eintragen dürfen, hält ihn die Leseregel auf.

Ist es sicher, diesen Fehler deinen Nutzern zu zeigen?

Nein. Zeig einen eigenen Satz und behalte den rohen Fehler in deinen Logs, aus zwei Gründen: Die Detailzeile kann den Wert zurückliefern, und der Fehler bestätigt, dass der Wert existiert, egal was du anzeigst.

Die Detailzeile wiederholt den Wert. Key (email)=(maria@example.com) ist auf Marias eigenem Bildschirm harmlos und etwas ganz anderes in einem Screenshot in einem Supportforum, in einem Error-Tracker oder auf dem Bildschirm von jemandem, der die Adresse einer anderen Person in dein Formular getippt hat. Postgres schützt sie schon. Die Funktion, die die Zeile baut (BuildIndexValueDescription), prüft zuerst: "if RLS is enabled for the relation. If so, return NULL to avoid leaking data." Eine Anfrage mit deinem öffentlichen Schlüssel an eine Tabelle mit eingeschalteter Row Level Security bekommt "details": null.

Andersherum gelesen verrät dir die Zeile etwas über die Tabelle. Zeigt deine Browserkonsole Key (...)=(...) für eine Anfrage, die deine App mit dem öffentlichen Schlüssel gestellt hat, ist Row Level Security auf dieser Tabelle aus. Die eine Ausnahme ist eine Anfrage, die deine App über Servercode mit dem geheimen Schlüssel schickt, der die Regeln komplett überspringt. null beweist weniger: Postgres lässt die Zeile auch weg, wenn der Aufrufer nicht jede Spalte im Schlüssel lesen darf. Row Level Security für jede Tabelle einschalten zeigt den Schalter und die erste Regel.

Eine Tabelle mit ausgeschalteten Regeln antwortet einem Fremden genauso bereitwillig wie dir. Unser kostenloser Scan liest deine Live-App mit demselben öffentlichen Schlüssel, den ein Besucher bekommt, meldet, welche Tabellen Zeilen herausgeben, und braucht kein Konto.

Der Fehler beantwortet eine Frage, die du vielleicht nicht beantwortet haben willst. Auch ohne Detailzeile sagt ein 409 dem Absender, dass der Wert schon da ist. Postgres' Seite über Row Security sagt, dass Unique-Regeln "always bypass row security to ensure that data integrity is maintained", und bittet dich, Tabellen und Regeln so zu entwerfen, dass diese Prüfungen nicht zu einem verdeckten Kanal werden. Auf der Warteliste kann jeder herausfinden, ob eine Adresse auf deiner Liste steht, indem er sie einzutragen versucht. Der Türsteher liest den Namen nie vor, und wenn er jemanden abweist, weiß die Schlange trotzdem, dass er dastand.

Wenn das für deine Liste zählt, gib dem Browser in beiden Fällen dieselbe Antwort. Eine Funktion in der Datenbank kann die Adresse eintragen, sie überspringen, wenn sie schon da ist, und in beiden Fällen nichts zurückgeben:

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;
$$;

Deine App ruft supabase.rpc('join_waitlist', { new_email: email }) auf. security definer führt das Insert als Eigentümer der Funktion aus, also brauchen Besucher überhaupt keine Regel auf der Tabelle, und die Regel, die sie eintragen ließ, kann weg, sonst bleibt der alte Weg neben dem neuen offen. Was "Database error saving new user" über security definer sagt erklärt, was es abgibt, und eine Supabase-Funktion aufrufen zeigt, wie die Argumentnamen passen müssen.

Postgres schreibt den Wert nur für einen Aufrufer in die Detailzeile, für den Row Level Security nicht gilt. Siehst du ihn im Browser, sind die Regeln der Tabelle aus.

Bevor du änderst, wie deine App schreibt

Behalte eine Kopie der Datenbank von vor dem Moment, in dem du ein Insert auf ein Upsert umstellst oder einen Zähler zurücksetzt, und erfahre es, wenn eine Tabelle anfängt, Fremden zu antworten.

Reeve Care bewahrt eine Kopie deiner Supabase-Datenbank auf:

  • eine verschlüsselte Kopie nach Zeitplan, auf Reeves eigenem Speicher in der EU, außerhalb deines Supabase-Kontos
  • jede Tabelle mit ihren Zeilen, wie sie waren, bevor ein Zusammenführen sie überschrieben hat
  • der ID-Zähler jeder Tabelle kommt bei einer Wiederherstellung mit ihren Zeilen zurück, sodass die nächste neue Zeile eine freie Nummer bekommt
  • jede Kopie wird geprüft, bevor sie zählt, indem die Zeilen in jeder Tabelle gezählt werden
  • auch deine hochgeladenen Dateien, sobald du eine Storage-Zugangsberechtigung verbindest
  • Wiederherstellung mit einem Klick, vorher wird eine Sicherheitskopie des aktuellen Stands angelegt
  • alles, was Monitor macht

Reeve Monitor prüft weiter von außen:

  • ob deine App erreichbar ist, jede Minute geprüft
  • der vollständige Sicherheitscheck jede Stunde, für bis zu drei Apps
  • eine Nachricht, wenn sich ein Ergebnis ändert, sodass eine Tabelle, die nach einem Deploy Fremden antwortet, dich erreicht, ohne dass du nachsehen musst
  • ein monatlicher Bericht in klarer Sprache über das, was er gesehen hat

Was du jetzt tun solltest

Was zu tun ist

  • Lies den Namen in Anführungszeichen. Tabelle und Spalte stehen vor _key, und _pkey heißt Primärschlüssel.
  • Endet der Name auf _pkey und deine App schickt keine id, rück den Zähler mit der setval-Anweisung oben zurecht.
  • Fang 23505 in deiner App ab und zeig einen Satz, den du geschrieben hast, nie die rohe Meldung.
  • Benutz upsert nur dort, wo die neue Zeile die alte ersetzen soll, mit onConflict auf der Spalte aus dem Fehler.
  • Zeigt die Browserkonsole eine Zeile Key (...)=(...) für eine Anfrage mit deinem öffentlichen Schlüssel, schalte Row Level Security für diese Tabelle ein.

Fang beim Namen in Anführungszeichen an, denn er sagt, welche Regel die Zeile abgelehnt hat und welche Spalte sie bewacht. Bevor du ein setval, ein Upsert oder irgendetwas anderes ausführst, das Zeilen umschreibt, auf die sich Leute verlassen, behalte eine Kopie der Datenbank von vor der Änderung.

FAQ

Was bedeutet duplicate key value violates unique constraint?

Postgres hat sich geweigert, eine Zeile anzulegen oder zu ändern, weil eine andere Zeile denselben Wert schon in einer Spalte oder Spaltengruppe hat, die jeden Wert nur einmal erlaubt. Geschrieben wurde nichts. Der Code ist 23505, und aus einer Supabase-App kommt er als HTTP 409.

Welche Spalte hat den Fehler ausgelöst?

Das sagt der Name der Regel in Anführungszeichen. Postgres benennt eine Unique-Regel nach Tabelle und Spalten mit _key am Ende, waitlist_email_key ist also die Spalte email der Tabelle waitlist, und ein Name auf _pkey ist der Primärschlüssel, meist id. Die Detailzeile nennt, wenn sie da ist, Spalte und Wert.

Soll ich upsert benutzen?

Nur wenn die neue Zeile die alte ersetzen soll, etwa eine Einstellungszeile pro Nutzer. Setz onConflict auf die Spalte aus dem Fehler, denn Supabase-Upserts prüfen standardmäßig den Primärschlüssel. Aus dem Browser braucht ein Upsert außerdem Leseregeln, die die Zeile besteht. Auf einer Tabelle, in die Besucher nur eintragen dürfen, scheitert er also, oder du musst die Tabelle zum Lesen öffnen.

Warum passiert das bei der Registrierung?

Zwei Wege führen dorthin: Ein Trigger kopiert jeden neuen Nutzer in eine eigene Tabelle, und der kopierte Wert steht dort schon, oder deine App legt dieselbe Profilzeile an, die der Trigger schon angelegt hat. Supabase meldet beides als "Database error saving new user", und den 23505 selbst findest du in den Postgres-Logs.

Ist es sicher, diesen Fehler Nutzern zu zeigen?

Nein. Die Detailzeile kann den Wert wiederholen, die Meldung nennt Tabelle und Spalte, und der Fehler selbst verrät dem Absender, dass der Wert existiert. Fang den Code 23505 ab, zeig einen Satz, den du geschrieben hast, und behalte den rohen Fehler in deinen eigenen Logs.

Geschrieben von

Vlad Tkachenko

Gründer, Reeve

Ich schaue mir den ganzen Tag Apps an, die mit Lovable, Bolt, v0, Cursor und Replit gebaut wurden, und die kurze Liste von Fehlern, die darin immer wieder auftauchen.

Mehr über den Autor

Weiterlesen

Alle Artikel

Sichere deine Supabase-Datenbank vor der nächsten Änderung

Reeve sichert sie verschlüsselt nach Zeitplan und stellt sie für dich wieder her, wenn etwas schiefgeht.

Supabase-Backups ansehen

Automatisierte externe Prüfung, kein vollständiges Audit. Das Fehlen von Funden ist keine Garantie für Sicherheit.