Zum Inhalt springen

Sicherheitsgrundlagen

Supabase-Fehler "null value in column violates not-null constraint"

Supabase "null value in column violates not-null constraint": Eine Pflichtspalte kam leer an. Woher die Null kommt, und die Lösung.

Vlad Tkachenko13 Min. Lesezeit
Ein Papierformular vor einer offenen Aktenschublade, sein zweites Sternchenfeld leer und bernsteinfarben umrandet.

Kurz gesagt

  • Supabase "null value in column violates not-null constraint" (Postgres-Code 23502, HTTP 400 in deiner App) heißt: Eine Zeile kam ohne Wert in einer Spalte an, die immer einen haben muss. Die Meldung nennt Spalte und Tabelle. Gespeichert wurde nichts.
  • Der leere Wert kommt aus einem Feld, das deine App nie schickt, aus einer Null, die dein Code absichtlich schickt, oder aus einer Spalte ohne den Standardwert, den du angenommen hast. Ein Upsert und ein Mehrfach-Insert können ihn beide bei einer Zeile auslösen, die vollständig aussieht.
  • Behalte die Regel und repariere die Zeile. Gib aus Servercode nie den rohen Fehler an den Browser weiter: Seine Zeile "Failing row contains" kann die ganze Zeile ausgeben.

Du drückst in deiner App bei einer neuen Aufgabe auf Speichern, und nichts wird gespeichert. Die Seite zeigt keinen Fehler, und die Liste bleibt, wie sie war. Die Browserkonsole kennt den Grund:

{
  "code": "23502",
  "details": null,
  "hint": null,
  "message": "null value in column \"category_id\" of relation \"tasks\" violates not-null constraint"
}

Supabase "null value in column violates not-null constraint" heißt, dass eine Zeile ohne Wert in einer Spalte bei deiner Datenbank angekommen ist, die immer einen Wert haben muss, und Postgres die ganze Zeile abgelehnt hat. Hier ist die Spalte category_id in deiner Tabelle tasks. Beim Speichern wurde keine Kategorie mitgeschickt.

Die schnellste Lösung ist, die Regel zu entfernen: eine Zeile SQL, und die Spalte darf leer bleiben. Der Fehler verschwindet, und ab dann kommen Zeilen mit leerem Feld an, auf die der Rest deiner App an einer Stelle trifft, an der du nicht hingeschaut hast. Finde zuerst heraus, woher der leere Wert kam. Es ist fast immer eine von wenigen Stellen.

Was bedeutet "null value in column violates not-null constraint"?

Eine Spalte der Zeile, die deine App speichern wollte, war leer, diese Spalte darf nie leer sein, und Postgres hat sich geweigert, irgendetwas von der Zeile zu speichern. Geschrieben wurde nichts. Null ist das Wort von Postgres für gar keinen Wert, und das ist etwas anderes als eine Null als Zahl oder ein Text ohne Buchstaben.

Stell dir jede Zeile als Papierformular vor und die Tabelle als Aktenschublade, in die es kommt. Eine Not-null-Regel ist ein Sternchen neben einem Feld. Postgres sitzt an der Schublade und legt kein Formular ab, bei dem ein Feld mit Sternchen leer ist. Das Formular kommt zurück, und die Meldung sagt, welches Feld.

FeldBeispielWas es dir sagt
code23502Der Code von Postgres für diesen Fehler, not_null_violation in seiner Liste der Codes
messagenull value in column "category_id" of relation "tasks" violates not-null constraintDie leere Spalte, dann die Tabelle. "Relation" ist das Wort von Postgres für eine Tabelle
detailsFailing row contains (…) oder nullEine Kopie der abgelehnten Zeile, wenn Postgres entscheidet, dass der Aufrufer sie sehen darf. Ein Abschnitt weiter unten zeigt, wann das so ist
hintnullNichts hinzuzufügen bei diesem Fehler
HTTP-Status400 Bad RequestPostgREST, der Teil von Supabase, der Aufrufe supabase.from(...) in SQL übersetzt, hat für 23502 keinen eigenen Fall, also landet sein Code bei 400

Der Wortlaut stammt von Postgres selbst, aus der Funktion, die den Fehler auslöst, und er liest sich gleich, ob die Zeile aus deiner App, einem Trigger oder dem SQL Editor kam.

Woher kam der leere Wert?

Meist von einer von drei Stellen im Code deiner eigenen App, und die Spalte in der Meldung sagt dir, wo du zuerst suchst. Zwei weitere beginnen in der Datenbank.

Ein Feld, das deine App nie schickt. Das ist der häufigste Fall. Die Spalte wurde zur Pflicht, nachdem das Formular gebaut war: Ein KI-Builder hat category_id zur Tabelle hinzugefügt, als du nach Kategorien gefragt hast, sie als Pflicht markiert und das Formular, das Aufgaben speichert, so gelassen, wie es war. Nichts in der Anfrage nennt die Spalte, die Spalte hat keinen Standardwert, also ist sie leer. Vergleich die Spalten in deinem Table Editor mit dem Objekt, das deine App an .insert() übergibt. Die fehlende ist die aus der Meldung.

Ein Schlüssel, der anders geschrieben ist als seine Spalte, kommt gar nicht so weit. PostgREST lehnt categoryId für eine Spalte namens category_id ab, bevor Postgres die Zeile sieht, und dieser Fehler hat einen eigenen Beitrag.

Eine Null, die dein Code absichtlich schickt. { category_id: selected?.id ?? null } schickt eine ausdrückliche Null, wenn nichts ausgewählt ist, und eine ausdrückliche Null ist für die Anfrage ein Wert. JavaScripts undefined verhält sich anders. JSON kennt diesen Wert nicht, also wird er von JSON.stringify "omitted (when found in an object)", und ein Schlüssel mit undefined erreicht Supabase überhaupt nicht. Welches von beiden dein Code erzeugt, entscheidet, ob ein Standardwert einspringen kann, und darum geht es im nächsten Abschnitt.

Eine Spalte ohne den Standardwert, den du angenommen hast. created_at füllt sich meist von selbst, weil die Tabelle default now() sagt. user_id meist nicht, denn nichts in Postgres weiß, wer angemeldet ist, solange man der Spalte nicht sagt, wie sie es herausfindet. Ein späterer Abschnitt zeigt, wie.

Eine Registrierung, unter einer anderen Meldung. Ein Trigger, der jeden neuen Nutzer in eine Tabelle profiles kopiert, liest seine Registrierungsdaten mit ->>, und die JSON-Operatoren von Postgres "return NULL, rather than failing, if the JSON input does not have the right structure to match the request; for example if no such key or array element exists". Ein Registrierungsformular, das nie full_name geschickt hat, gibt dem Trigger einen leeren Namen, eine Pflichtspalte full_name lehnt ihn ab, und Supabase meldet die ganze Registrierung als "Database error saving new user". Den 23502 selbst findest du in den Postgres-Logs, und dieser Beitrag zeigt, wo.

Ein Löschen an anderer Stelle. Ein Fremdschlüssel mit on delete set null leert die Spalte in jeder Zeile, die auf die gelöschte zeigte. Ist diese Spalte zugleich Pflicht, scheitert stattdessen das Löschen mit dieser Meldung, und der Beitrag über das Löschen von Nutzern zeigt, wie du eine passende Regel wählst.

"Aber die Spalte hat doch einen Standardwert"

Dann hat die Anfrage die Spalte genannt und leer geschickt. Postgres setzt einen Standardwert nur ein, wenn eine Spalte ganz aus der Zeile weggelassen wird. Seine Dokumentation zu Standardwerten sagt: "When a new row is created and no values are specified for some of the columns, those columns will be filled with their respective default values." Eine ausdrückliche Null zählt als angegebener Wert. Auf dem Formular gilt ein Feld mit vorgedrucktem Wert als ausgefüllt, bis jemand "keins" über den Aufdruck schreibt.

supabase-js schreibt "keins" an einer Stelle darüber, an der du es nicht erwarten würdest: bei einem Mehrfach-Insert, bei dem du ein Array von Zeilen übergibst. Es nennt jede Spalte, die in irgendeiner der Zeilen vorkommt, und sein Quellcode beschreibt die Option, die über die Lücken entscheidet, defaultToNull: "Make missing fields default to null. Otherwise, use the default value for the column. Only applies for bulk inserts." Sie steht auf true, solange du sie nicht änderst. Die Dokumentation von PostgREST sagt dasselbe für eine Anfrage, die ihre Spalten aufzählt, und genau das schickt supabase-js bei einem Array: "any missing columns in the payload will be inserted as null value by default."

Hier geht also die zweite Aufgabe mit leerem status hinein, selbst wenn die Tabelle für status den Standardwert 'todo' hat, und eine Pflichtspalte status lehnt das ganze Insert ab:

await supabase.from('tasks').insert([
  { title: 'Konzept schreiben', status: 'doing' },
  { title: 'Rechnung schicken' },
])

Beide Reparaturen funktionieren. Schick status in jeder Zeile mit, oder sag supabase-js, dass die Standardwerte der Tabelle stehen bleiben sollen:

await supabase.from('tasks').insert(rows, { defaultToNull: false })

Diese Option fügt den Header Prefer: missing=default hinzu, den PostgREST liest. Eine einzelne Zeile, als Objekt ohne Array darum übergeben, tut das nicht: Ein Schlüssel, der darin fehlt, fehlt auch im Insert, und der Standardwert greift.

Bei einem Mehrfach-Insert geht eine Spalte, die eine Zeile hat und eine andere nicht, bei der zweiten als null hinein. defaultToNull: false lässt stattdessen den Standardwert der Tabelle einspringen.

Warum scheitert ein Upsert daran, obwohl die Zeile existiert?

Weil Postgres die neue Zeile auf leere Pflichtspalten prüft, bevor es nach der vorhandenen Zeile sucht, die es ändern würde. Ein Upsert ist ein Formular mit dem Vermerk "ablegen, oder falls es für diese Person schon eine Akte gibt, ändern". Postgres prüft zuerst die Sternchen. Ein Formular, das nur ein neues Profilbild trägt, kommt zurück, obwohl die Akte, die es ändern sollte, in der Schublade liegt.

Die Reihenfolge steht in dem Code, der ein Insert ausführt: "Check the constraints of the tuple." kommt vor "Perform a speculative insertion.", und das ist der Schritt, der die Kollision findet, auf die eine Klausel on conflict reagiert. Tuple ist das Wort von Postgres für eine Zeile. Dieser Upsert, der eine Spalte eines vorhandenen Profils ändern soll, scheitert also an jeder anderen Spalte, die die Tabelle verlangt:

await supabase.from('profiles').upsert({ id: user.id, avatar_url: url })

Um einige Spalten einer Zeile zu ändern, die schon da ist, nimm ein Update. Es landet auf der gespeicherten Zeile, in der jede Pflichtspalte gefüllt ist, und ändert nur, was du schickst:

await supabase.from('profiles').update({ avatar_url: url }).eq('id', user.id)

Heb dir den Upsert für Zeilen auf, die es vielleicht noch nicht gibt, und schick jede Pflichtspalte mit. Der Beitrag über doppelte Schlüssel zeigt, was ein Upsert sonst noch braucht: auf welcher Spalte er abgleicht und welche Leseregeln er aus dem Browser anspricht.

Die Zeile eines Upserts wird auf leere Pflichtspalten geprüft, bevor Postgres nach der Zeile sucht, die es ändern würde. Ein Update beginnt bei der gespeicherten Zeile, die sie schon hat.

Solltest du die NOT-NULL-Regel entfernen?

Nur wenn die Spalte nie Pflicht sein sollte. Eine Telefonnummer, die dein Formular als optional behandelt, ist so ein Fall, und dort ist das Entfernen der Regel die richtige Reparatur:

alter table public.profiles alter column phone drop not null;

Bei jeder anderen Spalte behalte die Regel und füll das Feld. Ohne sie kann jeder Zeile, die danach gespeichert wird, der Wert fehlen, den die Regel garantiert hat, und deine App trifft woanders auf die Lücke: Eine Seite, die task.category.name liest, bricht mit Cannot read properties of null ab, oder eine Zählung der Aufgaben pro Kategorie verliert stillschweigend die ohne Kategorie.

Die Spalte, bei der das Entfernen am meisten kostet, ist user_id. Eine Zeile mit leerem Eigentümer sieht niemand. Eine Leseregel wie auth.uid() = user_id trifft nie auf sie zu, weil in SQL ein leerer Wert gleich nichts ist, nicht einmal einem anderen leeren Wert. Die Aufgabe ist gespeichert, und von da an sieht sie niemand, auch nicht die Person, die sie angelegt hat.

Die Reparatur für user_id ist ein Standardwert, der einsetzt, wer gerade angemeldet ist:

alter table public.tasks
  alter column user_id set default auth.uid();

Postgres wertet einen Standardwert "whenever the default value is inserted (not when the table is created)" aus, mit seinen eigenen Worten, also bekommt jede neue Zeile die id der Person, die speichert. Ist niemand angemeldet, gibt auth.uid() null zurück, wie Supabases Leitfaden zu Row Level Security warnt, und das Speichern scheitert weiterhin. So tut die Regel ihre Arbeit. Der Standardwert füllt das Feld nur, wenn deine App es weglässt, also hält weiterhin deine Insert-Regel jemanden davon ab, die id einer anderen Person zu schicken.

Diese Insert-Regel ändert auch, welche Meldung du siehst. Postgres führt die Row-Level-Security-Prüfungen einer Tabelle an einer neuen Zeile vor ihren Not-null-Prüfungen aus: derselbe Insert-Code hat "Check any RLS WITH CHECK policies." vor "Check the constraints of the tuple.". Vergleicht deine Insert-Regel user_id mit auth.uid(), scheitert ein leeres user_id also zuerst an der Regel und kommt als new row violates row-level security policy zurück. Ein 23502 bei user_id aus deiner App heißt eins von drei Dingen: Die Insert-Regel schaut sich user_id nie an, die Regeln sind auf dieser Tabelle aus, oder die Anfrage kam von Servercode, der sie überspringt.

Ist es sicher, diesen Fehler deinen Nutzern zu zeigen?

Nein. Zeig einen eigenen Satz und behalte den rohen Fehler in deinen Logs, denn die Detailzeile ist, wenn Postgres sie mitschickt, eine Kopie der ganzen Zeile.

Die Detailzeile eines doppelten Schlüssels wiederholt einen Wert. Diese hier wiederholt das Formular. Die Funktion, die sie schreibt, gibt die Zeile als Failing row contains (…) aus, mit jeder Spalte, die der Aufrufer lesen darf. Bei einem Update ist das die Zeile, wie sie gespeichert worden wäre, samt der Spalten, die niemand angefasst hat. Ein Formular, das Postgres zurückschickt, kann eine Fotokopie seiner selbst an der Ablehnung tragen, und auf der Kopie steht jedes Feld.

Postgres entscheidet, wer die Fotokopie bekommt, und sein Code prüft zuerst: "Check if RLS is enabled and should be active for the relation; if so, then don't return anything." Eine Anfrage mit deinem öffentlichen Schlüssel an eine Tabelle mit eingeschalteter Row Level Security bekommt "details": null, und genau das hat die Konsole oben auf dieser Seite gezeigt.

Zwei Aufrufer bekommen die volle Kopie.

Ein Browser, wenn die Regeln der Tabelle aus sind. Zeigt die Konsole Failing row contains für eine Anfrage, die deine App mit dem öffentlichen Schlüssel gestellt hat, ist Row Level Security auf dieser Tabelle aus. So liest auch der Beitrag über doppelte Schlüssel seine eigene Detailzeile, und die Regeln für jede Tabelle einschalten zeigt den Schalter und die erste Regel. Eine Tabelle mit ausgeschalteten Regeln gibt einem Fremden ihre Zeilen genauso bereitwillig wie dir. Unser kostenloser Scan liest deine Live-App mit dem öffentlichen Schlüssel, den jeder Besucher bekommt, und listet die Tabellen auf, die Zeilen herausgeben. Er braucht kein Konto.

Servercode mit dem geheimen Schlüssel. Supabases Leitfaden sagt, der geheime Schlüssel "authorizes access through the service_role Postgres role, which has the bypassrls attribute", also greifen die Regeln nie, und die Detailzeile ist immer gefüllt. Eine Edge Function, die eine Zeile ändert und mit dem Fehler antwortet, den sie bekommen hat, return new Response(JSON.stringify(error)), schickt dem Browser jede Spalte der Zeile, die sie ändern wollte, egal wem diese Zeile gehört.

Behalte den Fehler im Servercode und antworte mit eigenen Worten:

if (error) {
  console.error(error)
  return new Response(JSON.stringify({ message: 'Das konnte nicht gespeichert werden.' }), {
    status: 400,
  })
}

Fang in der App den Code ab und sag, was die Person tun kann:

const { error } = await supabase.from('tasks').insert(task)

if (error?.code === '23502') {
  setMessage('Wähl vor dem Speichern eine Kategorie.')
} else if (error) {
  setMessage('Etwas ist schiefgegangen. Bitte versuch es noch einmal.')
}

Einen geheimen Schlüssel in eine Edge Function verlegen zeigt die Seite der Funktion, falls der geheime Schlüssel noch in deiner App steckt.

Mit eingeschalteter Row Level Security bekommt der Browser keine Kopie der Zeile. Servercode mit dem geheimen Schlüssel bekommt sie ganz und reicht sie weiter, wenn er den Fehler so zurückgibt, wie er kam.

Bevor du die Regeln einer Spalte änderst

Behalte eine Kopie der Datenbank von vor dem Moment, in dem du einen Standardwert hinzufügst, eine Regel entfernst oder einen Wert über alle Zeilen hinweg einträgst, 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 Zeile, wie sie war, bevor ein Update über die ganze Tabelle sie umgeschrieben hat
  • 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 E-Mail an dem Tag, an dem ein erneuter Scan deine App schlechter benotet als der vorige, sodass eine Tabelle, deren Regeln abgeschaltet wurden, 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 Spalte und Tabelle in der Meldung. Das ist das Feld, das leer ankam.
  • Prüf, ob deine App dieses Feld weglässt, es als null schickt oder einen Standardwert erwartet, den die Spalte nicht hat.
  • Um einige Spalten einer vorhandenen Zeile zu ändern, nimm .update() mit einem Filter. Ein Upsert muss jede Pflichtspalte mitbringen.
  • Schick bei einem Mehrfach-Insert jede Spalte in jeder Zeile mit, oder übergib { defaultToNull: false }, damit die Standardwerte der Tabelle stehen bleiben.
  • Behalte die Regel auf user_id und gib der Spalte den Standardwert auth.uid().
  • Schreib den rohen Fehler im Servercode ins Log und antworte dem Browser mit einem eigenen Satz.

Fang bei der Spalte in der Meldung an, dann bei der Zeile deines Codes, die die Zeile zusammenbaut. Ändert die Reparatur die Tabelle selbst, mit einem neuen Standardwert oder einem Update über alle Zeilen, behalte eine Kopie der Datenbank von vor dem Ausführen.

FAQ

Was bedeutet null value in column violates not-null constraint?

Deine App wollte eine Zeile ohne Wert in einer Spalte speichern, die immer einen haben muss, und Postgres hat die ganze Zeile abgelehnt. Geschrieben wurde nichts. Die Meldung nennt Spalte und Tabelle. Der Code ist 23502, und aus einer Supabase-App kommt er als HTTP 400.

Warum ist die Spalte null, obwohl sie einen Standardwert hat?

Weil die Anfrage die Spalte genannt und leer geschickt hat. Postgres setzt einen Standardwert nur ein, wenn die Spalte weggelassen wird. supabase-js tut genau das bei einem Mehrfach-Insert: Eine Spalte, die eine Zeile hat und eine andere nicht, geht bei der zweiten als null hinein, außer du übergibst defaultToNull: false.

Warum scheitert mein Upsert mit einem Not-null-Fehler, obwohl die Zeile existiert?

Postgres prüft die neue Zeile auf leere Pflichtspalten, bevor es nach der vorhandenen Zeile sucht, die es ändern würde. Ein Upsert, der nur die zu ändernden Spalten trägt, scheitert an jeder anderen Pflichtspalte. Für eine Zeile, die es schon gibt, nimm update mit einem Filter.

Soll ich die NOT-NULL-Regel entfernen?

Nur wenn die Spalte nie Pflicht sein sollte, etwa eine Telefonnummer, die dein Formular als optional behandelt. Sonst verschwindet der Fehler, und Zeilen kommen ohne den Wert an, auf den deine App dann woanders trifft. Eine Zeile mit leerem user_id sieht niemand. Finde heraus, woher die Null kommt, oder gib der Spalte einen Standardwert wie auth.uid().

Ist es sicher, diesen Fehler Nutzern zu zeigen?

Nein. Wenn Postgres die Detailzeile mitschickt, ist sie eine Kopie der ganzen Zeile. Eine Browseranfrage an eine Tabelle mit eingeschalteter Row Level Security bekommt keine, Servercode mit dem geheimen Schlüssel dagegen immer. Fang den Code 23502 ab, zeig einen Satz, den du geschrieben hast, und behalte den rohen Fehler in deinen 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.