Bases de la sécurité
Erreur Supabase "null value in column violates not-null constraint"
Supabase "null value in column violates not-null constraint" : une colonne obligatoire est arrivée vide. D'où vient le null, et la solution.

En bref
- Supabase "null value in column violates not-null constraint" (code Postgres 23502, HTTP 400 dans votre application) signifie qu'une ligne est arrivée sans rien dans une colonne qui doit toujours avoir une valeur. Le message nomme la colonne et la table. Rien n'a été enregistré.
- La valeur vide vient d'un champ que votre application n'envoie jamais, d'un null que votre code envoie exprès, ou d'une colonne sans la valeur par défaut que vous supposiez. Un upsert comme une insertion multiple peuvent la produire sur une ligne qui semble complète.
- Gardez la règle et corrigez la ligne. Depuis du code serveur, ne renvoyez jamais l'erreur brute au navigateur : sa ligne "Failing row contains" peut afficher la ligne entière.
Vous appuyez sur Enregistrer pour une nouvelle tâche dans votre application, et rien n'est enregistré. La page n'affiche aucune erreur et la liste reste telle quelle. La console du navigateur, elle, donne la raison :
{
"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" signifie qu'une
ligne a atteint votre base de données sans rien dans une colonne qui doit
toujours avoir une valeur, et que Postgres a refusé la ligne entière. Ici, la
colonne est category_id dans votre table tasks. L'enregistrement n'a envoyé
aucune catégorie.
La solution la plus rapide consiste à retirer la règle : une ligne de SQL, et la colonne peut rester vide. L'erreur disparaît, et des lignes arrivent dès lors avec cette case vide, que le reste de votre application rencontre là où vous ne regardiez pas. Trouvez d'abord d'où vient la valeur vide. C'est presque toujours l'un de quelques endroits.
Que signifie "null value in column violates not-null constraint" ?
Une colonne de la ligne que votre application voulait enregistrer était vide, cette colonne ne doit jamais l'être, et Postgres a refusé d'enregistrer quoi que ce soit de la ligne. Rien n'a été écrit. Null est le mot de Postgres pour l'absence de valeur, ce qui n'est ni un zéro ni un texte sans aucune lettre.
Imaginez chaque ligne comme un formulaire papier et la table comme le tiroir de classement où il est rangé. Une contrainte not-null est un astérisque imprimé à côté d'une case. Postgres se tient au tiroir et ne classe aucun formulaire dont une case marquée d'un astérisque est vide. Le formulaire revient, et le message dit quelle case.
| Champ | Exemple | Ce qu'il vous dit |
|---|---|---|
code | 23502 | Le code de Postgres pour cette erreur, nommé not_null_violation dans sa liste de codes |
message | null value in column "category_id" of relation "tasks" violates not-null constraint | La colonne vide, puis la table. "Relation" est le mot de Postgres pour une table |
details | Failing row contains (…), ou null | Une copie de la ligne refusée, quand Postgres décide que l'appelant peut la voir. Une section plus bas explique quand |
hint | null | Rien à ajouter pour cette erreur |
| Statut HTTP | 400 Bad Request | PostgREST, la partie de Supabase qui transforme les appels supabase.from(...) en SQL, n'a pas de cas particulier pour 23502, donc son code retombe sur 400 |
La formulation est celle de Postgres, tirée de la fonction qui lève l'erreur, et elle se lit de la même façon que la ligne vienne de votre application, d'un trigger ou du SQL Editor.
D'où vient la valeur vide ?
Le plus souvent de l'un de trois endroits du code de votre propre application, et la colonne du message vous dit où regarder en premier. Deux autres commencent dans la base de données.
Un champ que votre application n'envoie jamais. C'est le cas le plus courant.
La colonne est devenue obligatoire après la création du formulaire : un outil de
création par IA a ajouté category_id à la table quand vous avez demandé des
catégories, l'a marquée obligatoire, et a laissé tel quel le formulaire qui
enregistre les tâches. Rien dans la requête ne nomme la colonne, la colonne n'a
pas de valeur par défaut, donc elle est vide. Comparez les colonnes de votre
Table Editor avec l'objet que votre application passe à .insert(). Celle qui
manque est celle du message.
Une clé écrite autrement que sa colonne n'arrive même pas jusque-là. PostgREST
refuse categoryId envoyé à une colonne nommée category_id avant que Postgres
ne voie la ligne, et
cette erreur a son propre article.
Un null que votre code envoie exprès. { category_id: selected?.id ?? null }
envoie un null explicite quand rien n'est sélectionné, et un null explicite est
une valeur aux yeux de la requête. Le undefined de JavaScript se comporte
autrement. JSON n'a pas cette valeur, donc
JSON.stringify
la laisse "omitted (when found in an object)", et une clé à undefined n'atteint
jamais Supabase. Lequel des deux votre code produit décide si une valeur par
défaut peut prendre le relais, et c'est le sujet de la section suivante.
Une colonne sans la valeur par défaut que vous supposiez. created_at se
remplit généralement tout seul, parce que la table indique default now().
user_id généralement pas, car rien dans Postgres ne sait qui est connecté tant
qu'on n'a pas dit à la colonne comment le trouver. Une section plus loin montre
comment.
Une inscription, sous un autre message. Un trigger qui copie chaque nouvel
utilisateur dans une table profiles lit ses informations d'inscription avec
->>, et
les opérateurs JSON de 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". Un formulaire d'inscription qui n'a jamais envoyé full_name donne au
trigger un nom vide, une colonne obligatoire full_name le refuse, et Supabase
signale toute l'inscription comme
"Database error saving new user". Le
23502 lui-même se trouve dans les journaux Postgres, et cet article montre où.
Une suppression ailleurs. Une clé étrangère réglée sur on delete set null
vide la colonne dans chaque ligne qui pointait vers la ligne supprimée. Si cette
colonne est aussi obligatoire, c'est la suppression qui échoue avec ce message, et
l'article sur la suppression d'utilisateurs
explique comment choisir une règle adaptée.
"Mais la colonne a une valeur par défaut"
Alors la requête a nommé la colonne et l'a envoyée vide. Postgres n'applique une valeur par défaut que si une colonne est entièrement absente de la ligne. Sa documentation sur les valeurs par défaut dit : "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." Un null explicite compte comme une valeur indiquée. Sur le formulaire, une case pré-imprimée compte comme remplie jusqu'à ce que quelqu'un écrive "aucun" par-dessus l'impression.
supabase-js écrit "aucun" par-dessus à un endroit inattendu : une insertion
multiple, où vous passez un tableau de lignes. Il nomme chaque colonne présente
dans l'une quelconque des lignes, et
son code source
documente l'option qui décide du sort des trous, defaultToNull : "Make missing
fields default to null. Otherwise, use the default value for the column. Only
applies for bulk inserts." Elle vaut true tant que vous ne la changez pas.
La documentation de PostgREST
dit la même chose pour une requête qui liste ses colonnes, ce que supabase-js
envoie pour un tableau : "any missing columns in the payload will be inserted as
null value by default."
Ici, la deuxième tâche entre donc avec status vide, même si la table donne à
status la valeur par défaut 'todo', et une colonne status obligatoire refuse
toute l'insertion :
await supabase.from('tasks').insert([
{ title: 'Rédiger le brief', status: 'doing' },
{ title: 'Envoyer la facture' },
])
Les deux corrections marchent. Envoyez status dans chaque ligne, ou dites à
supabase-js de laisser les valeurs par défaut de la table s'appliquer :
await supabase.from('tasks').insert(rows, { defaultToNull: false })
Cette option ajoute l'en-tête Prefer: missing=default que lit PostgREST. Une
seule ligne passée comme objet, sans tableau autour, ne fait pas cela : une clé
absente de l'objet est absente de l'insertion, et la valeur par défaut
s'applique.
Pourquoi un upsert échoue-t-il ainsi alors que la ligne existe ?
Parce que Postgres vérifie que la nouvelle ligne n'a pas de colonne obligatoire vide avant de chercher la ligne existante qu'il modifierait. Un upsert est un formulaire marqué "à classer, ou, s'il existe déjà un dossier pour cette personne, à modifier". Postgres vérifie d'abord les astérisques. Un formulaire qui ne porte qu'une nouvelle photo de profil revient, alors que le dossier qu'il devait modifier est dans le tiroir.
L'ordre est écrit dans
le code qui exécute une insertion :
"Check the constraints of the tuple." vient avant "Perform a speculative
insertion.", l'étape qui trouve le conflit auquel répond une clause
on conflict. Tuple est le mot de Postgres pour une ligne. Cet upsert, censé
changer une colonne d'un profil existant, échoue donc sur toute autre colonne que
la table exige :
await supabase.from('profiles').upsert({ id: user.id, avatar_url: url })
Pour changer quelques colonnes d'une ligne déjà là, utilisez un update. Il porte sur la ligne enregistrée, dont toutes les colonnes obligatoires sont remplies, et ne change que ce que vous envoyez :
await supabase.from('profiles').update({ avatar_url: url }).eq('id', user.id)
Gardez upsert pour des lignes qui n'existent peut-être pas encore, et envoyez-le avec chaque colonne obligatoire. L'article sur les clés en double couvre le reste de ce qu'il faut à un upsert : la colonne sur laquelle il compare, et les règles de lecture qu'il sollicite depuis le navigateur.
Faut-il supprimer la règle NOT NULL ?
Seulement si la colonne n'aurait jamais dû être obligatoire. Un numéro de téléphone que votre formulaire traite comme facultatif est ce cas-là, et retirer la règle y est la bonne correction :
alter table public.profiles alter column phone drop not null;
Pour toute autre colonne, gardez la règle et remplissez la case. Sans elle, chaque
ligne enregistrée ensuite peut manquer de la valeur que la règle garantissait, et
votre application rencontre le trou ailleurs : une page qui lit
task.category.name s'arrête sur Cannot read properties of null, ou un
décompte des tâches par catégorie perd en silence celles qui n'en ont pas.
La colonne où la retirer coûte le plus, c'est user_id. Une ligne enregistrée
avec un propriétaire vide n'est montrée à personne. Une règle de lecture écrite
auth.uid() = user_id ne la retient jamais, car en SQL une valeur vide n'est
égale à rien, pas même à une autre valeur vide. La tâche est enregistrée, et
désormais personne ne la voit, pas même la personne qui l'a créée.
La correction pour user_id est une valeur par défaut qui inscrit la personne
connectée :
alter table public.tasks
alter column user_id set default auth.uid();
Postgres évalue une valeur par défaut "whenever the default value is inserted
(not when the table is created)", selon
ses propres termes,
donc chaque nouvelle ligne reçoit l'id de la personne qui enregistre. Sans
personne de connecté, auth.uid() renvoie null, comme
le guide de Supabase sur la row level security
le signale, et l'enregistrement échoue toujours. C'est la règle qui fait son
travail. La valeur par défaut ne remplit la case que si votre application
l'omet, donc ce qui empêche quelqu'un d'envoyer l'id d'une autre personne reste
votre règle d'insertion.
Cette règle d'insertion change aussi le message que vous voyez. Postgres exécute
les vérifications de Row Level Security d'une table sur une nouvelle ligne avant
ses vérifications not-null :
le même code d'insertion
place "Check any RLS WITH CHECK policies." avant "Check the constraints of the
tuple.". Quand votre règle d'insertion compare user_id à auth.uid(), un
user_id vide échoue donc d'abord sur la règle et revient sous la forme
new row violates row-level security policy.
Un 23502 sur user_id venant de votre application signifie l'une de trois
choses : la règle d'insertion ne regarde jamais user_id, les règles sont
désactivées sur cette table, ou la requête venait de code serveur qui les
contourne.
Peut-on montrer cette erreur à vos utilisateurs sans risque ?
Non. Affichez une phrase à vous et gardez l'erreur brute dans vos journaux, car la ligne de détail, quand Postgres l'inclut, est une copie de la ligne entière.
La ligne de détail d'une clé en double répète une valeur. Celle-ci répète le
formulaire.
La fonction qui l'écrit
affiche la ligne sous la forme Failing row contains (…), avec chaque colonne
que l'appelant a le droit de lire. Pour un update, c'est la ligne telle qu'elle
aurait été enregistrée, y compris les colonnes que personne n'a touchées. Un
formulaire que Postgres renvoie peut porter une photocopie de lui-même agrafée au
refus, et la copie montre chaque case.
Postgres décide qui reçoit la photocopie, et son code vérifie d'abord : "Check if
RLS is enabled and should be active for the relation; if so, then don't return
anything." Une requête faite avec votre clé publique vers une table où la Row
Level Security est activée reçoit "details": null, ce que montrait la console
en haut de cette page.
Deux appelants reçoivent la copie complète.
Un navigateur, quand les règles de la table sont désactivées. Si la console
affiche Failing row contains pour une requête que votre application a faite
avec la clé publique, la Row Level Security est désactivée sur cette table. C'est
la même lecture que
l'article sur les clés en double
fait de sa propre ligne de détail, et
activer les règles sur chaque table
couvre l'interrupteur et la première règle. Une table aux règles désactivées
donne ses lignes à un inconnu aussi volontiers qu'à vous. Notre
scan gratuit lit votre application en ligne avec la clé
publique que reçoit n'importe quel visiteur et liste les tables qui renvoient des
lignes. Il ne demande aucun compte.
Du code serveur qui détient la clé secrète. Le guide de Supabase dit que la
clé secrète "authorizes access through the service_role Postgres role, which
has the bypassrls attribute", donc les règles ne s'appliquent jamais et la
ligne de détail est toujours remplie. Une Edge Function qui modifie une ligne et
répond avec l'erreur reçue, return new Response(JSON.stringify(error)), envoie
au navigateur chaque colonne de la ligne qu'elle modifiait, quel qu'en soit le
propriétaire.
Dans le code serveur, gardez l'erreur et répondez avec vos propres mots :
if (error) {
console.error(error)
return new Response(JSON.stringify({ message: "Impossible d'enregistrer." }), {
status: 400,
})
}
Dans l'application, interceptez le code et dites à la personne ce qu'elle peut faire :
const { error } = await supabase.from('tasks').insert(task)
if (error?.code === '23502') {
setMessage("Choisissez une catégorie avant d'enregistrer.")
} else if (error) {
setMessage('Un problème est survenu. Veuillez réessayer.')
}
Déplacer une clé secrète dans une Edge Function couvre la partie fonction, si la clé secrète est encore dans votre application.
Avant de changer les règles d'une colonne
Gardez une copie de la base de données d'avant le moment où vous ajoutez une valeur par défaut, retirez une règle ou inscrivez une valeur sur toutes les lignes, et soyez prévenu quand une table se met à répondre à des inconnus.
Reeve Care garde une copie de votre base de données Supabase :
- une copie chiffrée selon un calendrier, sur le stockage propre de Reeve dans l'UE, en dehors de votre compte Supabase
- chaque ligne telle qu'elle était avant qu'un update sur toute la table la réécrive
- chaque copie vérifiée avant de compter, en comptant les lignes de chaque table
- vos fichiers envoyés aussi, dès que vous connectez un identifiant Storage
- une restauration en un clic, avec une copie de sécurité de l'état actuel prise d'abord
- tout ce que fait Monitor
Reeve Monitor continue de vérifier de l'extérieur :
- si votre application répond, vérifié chaque minute
- la vérification de sécurité complète toutes les heures, sur trois applications au plus
- un e-mail le jour où un nouveau scan note votre application moins bien que le précédent, pour qu'une table dont les règles ont été désactivées vous parvienne sans que vous alliez voir
- un rapport mensuel, en langage clair, de ce qu'il a vu
Que faire maintenant
Que faire
- Lisez la colonne et la table dans le message. C'est la case arrivée vide.
- Vérifiez si votre application omet ce champ, l'envoie comme
null, ou attend une valeur par défaut que la colonne n'a pas. - Pour changer quelques colonnes d'une ligne existante, utilisez
.update()avec un filtre. Un upsert doit porter chaque colonne obligatoire. - Dans une insertion multiple, envoyez chaque colonne dans chaque ligne, ou passez
{ defaultToNull: false }pour que les valeurs par défaut de la table s'appliquent. - Gardez la règle sur
user_idet donnez à la colonne la valeur par défautauth.uid(). - Depuis du code serveur, journalisez l'erreur brute et répondez au navigateur avec une phrase à vous.
Commencez par la colonne du message, puis par la ligne de votre code qui construit la ligne. Si la correction modifie la table elle-même, une nouvelle valeur par défaut ou un update sur toutes les lignes, gardez une copie de la base de données d'avant de l'exécuter.
FAQ
Que signifie null value in column violates not-null constraint ?
Votre application a voulu enregistrer une ligne sans valeur dans une colonne qui doit toujours en avoir une, et Postgres a refusé la ligne entière. Rien n'a été écrit. Le message nomme la colonne et la table. Le code est 23502, et depuis une application Supabase il arrive en HTTP 400.
Pourquoi la colonne est-elle null alors qu'elle a une valeur par défaut ?
Parce que la requête a nommé la colonne et l'a envoyée vide. Postgres n'applique une valeur par défaut que si la colonne est omise. supabase-js fait exactement cela dans une insertion multiple : une colonne présente dans une ligne et absente d'une autre entre en null pour celle qui ne l'a pas, sauf si vous passez defaultToNull: false.
Pourquoi mon upsert échoue-t-il avec une erreur not-null alors que la ligne existe ?
Postgres vérifie que la nouvelle ligne n'a pas de colonne obligatoire vide avant de chercher la ligne existante à modifier. Un upsert qui ne porte que les colonnes à changer échoue sur toute autre colonne obligatoire. Pour une ligne qui existe déjà, utilisez update avec un filtre.
Faut-il supprimer la contrainte NOT NULL ?
Seulement si la colonne n'aurait jamais dû être obligatoire, comme un numéro de téléphone que votre formulaire traite comme facultatif. Sinon l'erreur s'arrête et des lignes arrivent sans la valeur, que votre application rencontre ailleurs. Une ligne avec un user_id vide n'est montrée à personne. Trouvez d'où vient le null, ou donnez à la colonne une valeur par défaut comme auth.uid().
Peut-on montrer cette erreur aux utilisateurs sans risque ?
Non. Quand Postgres inclut la ligne de détail, c'est une copie de la ligne entière. Une requête du navigateur vers une table avec la Row Level Security activée n'en reçoit aucune, mais du code serveur avec la clé secrète la reçoit toujours. Interceptez le code 23502, affichez une phrase que vous avez écrite et gardez l'erreur brute dans vos journaux.