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.

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.
| Feld | Beispiel | Was es dir sagt |
|---|---|---|
code | 23502 | Der Code von Postgres für diesen Fehler, not_null_violation in seiner Liste der Codes |
message | null value in column "category_id" of relation "tasks" violates not-null constraint | Die leere Spalte, dann die Tabelle. "Relation" ist das Wort von Postgres für eine Tabelle |
details | Failing row contains (…) oder null | Eine Kopie der abgelehnten Zeile, wenn Postgres entscheidet, dass der Aufrufer sie sehen darf. Ein Abschnitt weiter unten zeigt, wann das so ist |
hint | null | Nichts hinzuzufügen bei diesem Fehler |
| HTTP-Status | 400 Bad Request | PostgREST, 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.
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.
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.
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
nullschickt 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_idund gib der Spalte den Standardwertauth.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.