Basi della sicurezza
Errore Supabase "null value in column violates not-null constraint"
Supabase "null value in column violates not-null constraint": una colonna obbligatoria è arrivata vuota. Da dove viene il null, e la soluzione.

In breve
- Supabase "null value in column violates not-null constraint" (codice Postgres 23502, HTTP 400 nella tua app) significa che una riga è arrivata senza nulla in una colonna che deve sempre avere un valore. Il messaggio nomina la colonna e la tabella. Non è stato salvato niente.
- Il valore vuoto arriva da un campo che la tua app non manda mai, da un null che il tuo codice manda apposta o da una colonna senza il valore predefinito che davi per scontato. Un upsert e un inserimento multiplo possono produrlo su una riga che sembra completa.
- Tieni la regola e sistema la riga. Dal codice server, non restituire mai l'errore grezzo al browser: la sua riga "Failing row contains" può mostrare la riga intera.
Premi Salva su una nuova attività nella tua app e non viene salvato niente. La pagina non mostra errori e l'elenco resta com'era. La console del browser ha il motivo:
{
"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" significa che una
riga è arrivata al tuo database senza nulla in una colonna che deve sempre avere
un valore, e Postgres ha rifiutato la riga intera. Qui la colonna è category_id
nella tua tabella tasks. Il salvataggio non ha mandato nessuna categoria.
La correzione più rapida è togliere la regola: una riga di SQL e la colonna può restare vuota. L'errore sparisce, e da quel momento arrivano righe con quel campo in bianco, che il resto della tua app incontra dove non stavi guardando. Scopri prima da dove è arrivato il valore vuoto. Quasi sempre è uno di pochi punti.
Che cosa significa "null value in column violates not-null constraint"?
Significa che una colonna della riga che la tua app voleva salvare era vuota, che quella colonna non può mai esserlo, e che Postgres si è rifiutato di salvare qualsiasi parte della riga. Non è stato scritto niente. Null è la parola di Postgres per l'assenza di valore, che non è uno zero né un testo senza lettere.
Pensa a ogni riga come a un modulo di carta e alla tabella come al cassetto dell'archivio in cui finisce. Un vincolo not-null è un asterisco stampato accanto a un campo. Postgres sta al cassetto e non archivia nessun modulo con un campo con asterisco lasciato vuoto. Il modulo torna indietro, e il messaggio dice quale campo.
| Campo | Esempio | Che cosa ti dice |
|---|---|---|
code | 23502 | Il codice di Postgres per questo errore, chiamato not_null_violation nel suo elenco dei codici |
message | null value in column "category_id" of relation "tasks" violates not-null constraint | La colonna vuota, poi la tabella. "Relation" è la parola di Postgres per una tabella |
details | Failing row contains (…), oppure null | Una copia della riga rifiutata, quando Postgres decide che chi chiama può vederla. Una sezione più sotto spiega quando |
hint | null | Niente da aggiungere per questo errore |
| Stato HTTP | 400 Bad Request | PostgREST, la parte di Supabase che trasforma le chiamate supabase.from(...) in SQL, non ha un caso apposta per 23502, quindi il suo codice ricade su 400 |
Il testo è quello di Postgres, dalla funzione che solleva l'errore, e si legge allo stesso modo che la riga arrivi dalla tua app, da un trigger o dal SQL Editor.
Da dove è arrivato il valore vuoto?
Di solito da uno di tre punti nel codice della tua app, e la colonna nel messaggio ti dice dove guardare per primo. Altri due partono dentro il database.
Un campo che la tua app non manda mai. È il caso più comune. La colonna è
diventata obbligatoria dopo che il modulo era stato fatto: uno strumento di
sviluppo con IA ha aggiunto category_id alla tabella quando hai chiesto le
categorie, l'ha resa obbligatoria e ha lasciato com'era il modulo che salva le
attività. Niente nella richiesta nomina la colonna, la colonna non ha un valore
predefinito, quindi resta vuota. Confronta le colonne nel tuo Table Editor con
l'oggetto che la tua app passa a .insert(). Quella che manca è quella del
messaggio.
Una chiave scritta diversamente dalla sua colonna non arriva nemmeno fin qui.
PostgREST rifiuta categoryId mandato a una colonna chiamata category_id prima
che Postgres veda la riga, e
quell'errore ha un articolo tutto suo.
Un null che il tuo codice manda apposta. { category_id: selected?.id ?? null }
manda un null esplicito quando non è selezionato niente, e un null esplicito per
la richiesta è un valore. L'undefined di JavaScript si comporta in un altro modo.
JSON non ha questo valore, quindi
JSON.stringify
lo lascia "omitted (when found in an object)", e una chiave impostata a
undefined non arriva mai a Supabase. Quale dei due produce il tuo codice decide
se un valore predefinito può entrare in gioco, ed è l'argomento della prossima
sezione.
Una colonna senza il valore predefinito che davi per scontato. created_at di
solito si riempie da sola, perché la tabella dice default now(). user_id di
solito no, perché niente in Postgres sa chi ha fatto l'accesso finché non dici
alla colonna come scoprirlo. Una sezione più avanti mostra come.
Una registrazione, sotto un altro messaggio. Un trigger che copia ogni nuovo
utente in una tabella profiles legge i suoi dati di registrazione con ->>, e
gli operatori JSON di 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 modulo di registrazione che non ha mai mandato full_name dà al
trigger un nome vuoto, una colonna obbligatoria full_name lo rifiuta, e Supabase
segnala l'intera registrazione come
"Database error saving new user". Il
23502 vero e proprio sta nei log di Postgres, e quell'articolo mostra dove.
Un'eliminazione da un'altra parte. Una chiave esterna impostata su
on delete set null svuota la colonna in ogni riga che puntava a quella
eliminata. Se quella colonna è anche obbligatoria, a fallire con questo messaggio
è l'eliminazione, e
l'articolo sull'eliminazione degli utenti
spiega come scegliere una regola adatta.
"Ma la colonna ha un valore predefinito"
Allora la richiesta ha nominato la colonna e l'ha mandata vuota. Postgres usa un valore predefinito solo quando una colonna resta del tutto fuori dalla riga. La sua documentazione sui valori predefiniti dice: "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 esplicito conta come valore indicato. Sul modulo, un campo con un valore prestampato conta come compilato finché qualcuno non scrive "nessuno" sopra la stampa.
supabase-js scrive "nessuno" sopra in un punto che non ti aspetteresti: un
inserimento multiplo, in cui passi un array di righe. Nomina ogni colonna che
compare in una qualsiasi delle righe, e
il suo codice sorgente
documenta l'opzione che decide cosa succede ai buchi, defaultToNull: "Make
missing fields default to null. Otherwise, use the default value for the
column. Only applies for bulk inserts." Vale true finché non la cambi.
La documentazione di PostgREST
dice lo stesso per una richiesta che elenca le sue colonne, ed è quello che
supabase-js manda con un array: "any missing columns in the payload will be
inserted as null value by default."
Qui quindi la seconda attività entra con status vuoto, anche se la tabella dà a
status il valore predefinito 'todo', e uno status obbligatorio rifiuta
l'intero inserimento:
await supabase.from('tasks').insert([
{ title: 'Scrivere il brief', status: 'doing' },
{ title: 'Mandare la fattura' },
])
Va bene una qualsiasi delle due correzioni. Manda status in ogni riga, oppure di'
a supabase-js di lasciare valere i valori predefiniti della tabella:
await supabase.from('tasks').insert(rows, { defaultToNull: false })
Quell'opzione aggiunge l'intestazione Prefer: missing=default che PostgREST
legge. Una sola riga passata come oggetto, senza l'array intorno, non lo fa: una
chiave che manca nell'oggetto manca nell'inserimento, e vale il valore
predefinito.
Perché un upsert fallisce così se la riga esiste già?
Perché Postgres controlla che la nuova riga non abbia colonne obbligatorie vuote prima di cercare la riga esistente che aggiornerebbe. Un upsert è un modulo con scritto "archivialo, oppure, se c'è già una pratica per questa persona, modificala". Postgres controlla prima gli asterischi. Un modulo che porta solo una nuova foto del profilo torna indietro, anche se la pratica che doveva modificare è lì nel cassetto.
L'ordine è scritto nel
codice che esegue un inserimento:
"Check the constraints of the tuple." viene prima di "Perform a speculative
insertion.", che è il passo che trova il conflitto a cui risponde una clausola
on conflict. Tuple è la parola di Postgres per una riga. Questo upsert, pensato
per cambiare una colonna di un profilo che esiste, fallisce quindi su qualsiasi
altra colonna richiesta dalla tabella:
await supabase.from('profiles').upsert({ id: user.id, avatar_url: url })
Per cambiare alcune colonne di una riga che c'è già, usa un update. Agisce sulla riga salvata, che ha tutte le colonne obbligatorie riempite, e cambia solo quello che mandi:
await supabase.from('profiles').update({ avatar_url: url }).eq('id', user.id)
Tieni l'upsert per le righe che forse non esistono ancora, e mandalo con ogni colonna obbligatoria. L'articolo sulle chiavi duplicate copre il resto di quello che serve a un upsert: su quale colonna confronta e quali regole di lettura chiama in causa dal browser.
Dovresti togliere la regola NOT NULL?
Solo se la colonna non doveva mai essere obbligatoria. Un numero di telefono che il tuo modulo tratta come facoltativo è quel caso, e lì togliere la regola è la correzione giusta:
alter table public.profiles alter column phone drop not null;
Per ogni altra colonna, tieni la regola e riempi il campo. Senza, ogni riga
salvata dopo può restare senza il valore che la regola garantiva, e la tua app
incontra il buco altrove: una pagina che legge task.category.name si ferma con
Cannot read properties of null, oppure un conteggio delle attività per categoria
perde in silenzio quelle che non ne hanno.
La colonna dove toglierla costa di più è user_id. Una riga salvata con il
proprietario vuoto non viene mostrata a nessuno. Una regola di lettura scritta
come auth.uid() = user_id non la prende mai, perché in SQL un valore vuoto non è
uguale a niente, nemmeno a un altro valore vuoto. L'attività è salvata, e da quel
momento non la vede nessuno, nemmeno la persona che l'ha creata.
La correzione per user_id è un valore predefinito che inserisce chi ha fatto
l'accesso:
alter table public.tasks
alter column user_id set default auth.uid();
Postgres valuta un valore predefinito "whenever the default value is inserted
(not when the table is created)", con
parole sue, quindi
ogni nuova riga riceve l'id della persona che salva. Se nessuno ha fatto
l'accesso, auth.uid() restituisce null, come avverte
la guida di Supabase alla row level security,
e il salvataggio continua a fallire. È la regola che fa il suo lavoro. Il valore
predefinito riempie il campo solo quando la tua app lo lascia fuori, quindi a
impedire che qualcuno mandi l'id di un'altra persona resta la tua regola di
inserimento.
Quella regola di inserimento cambia anche il messaggio che vedi. Postgres esegue i
controlli di Row Level Security di una tabella su una nuova riga prima dei suoi
controlli not-null:
lo stesso codice di inserimento
mette "Check any RLS WITH CHECK policies." prima di "Check the constraints of the
tuple.". Quando la tua regola di inserimento confronta user_id con auth.uid(),
uno user_id vuoto fallisce quindi prima sulla regola e torna come
new row violates row-level security policy.
Un 23502 su user_id dalla tua app significa una di tre cose: la regola di
inserimento non guarda mai user_id, le regole sono disattivate su quella
tabella, oppure la richiesta arrivava da codice server che le salta.
È sicuro mostrare questo errore ai tuoi utenti?
No. Mostra una frase tua e tieni l'errore grezzo nei tuoi log, perché la riga di dettaglio, quando Postgres la include, è una copia della riga intera.
La riga di dettaglio di una chiave duplicata ripete un valore. Questa ripete il
modulo.
La funzione che la scrive
mostra la riga come Failing row contains (…), con ogni colonna che chi chiama
può leggere. In un update è la riga come sarebbe stata salvata, comprese le
colonne che nessuno ha toccato. Un modulo che Postgres rimanda indietro può
arrivare con una fotocopia di sé spillata al rifiuto, e la copia mostra ogni
campo.
Postgres decide chi riceve la fotocopia, e il suo codice controlla per prima cosa:
"Check if RLS is enabled and should be active for the relation; if so, then don't
return anything." Una richiesta fatta con la tua chiave pubblica a una tabella con
la Row Level Security attiva riceve "details": null, che è quello che mostrava
la console in cima a questa pagina.
Due chiamanti ricevono la copia completa.
Un browser, quando le regole della tabella sono disattivate. Se la console
mostra Failing row contains per una richiesta che la tua app ha fatto con la
chiave pubblica, la Row Level Security su quella tabella è disattivata. È la
stessa lettura che
l'articolo sulle chiavi duplicate
fa della sua riga di dettaglio, e
attivare le regole su ogni tabella
spiega l'interruttore e la prima regola. Una tabella con le regole disattivate dà
le sue righe a uno sconosciuto con la stessa facilità con cui le dà a te. La
nostra scansione gratuita legge la tua app online con la
chiave pubblica che riceve qualsiasi visitatore ed elenca le tabelle che
restituiscono righe. Non serve un account.
Codice server che ha la chiave segreta. La guida di Supabase dice che la
chiave segreta "authorizes access through the service_role Postgres role, which
has the bypassrls attribute", quindi le regole non si applicano mai e la riga di
dettaglio è sempre piena. Una Edge Function che aggiorna una riga e risponde con
l'errore ricevuto, return new Response(JSON.stringify(error)), manda al browser
ogni colonna della riga che stava cambiando, di chiunque sia quella riga.
Nel codice server, tieni l'errore e rispondi con parole tue:
if (error) {
console.error(error)
return new Response(JSON.stringify({ message: 'Impossibile salvare.' }), {
status: 400,
})
}
Nell'app, intercetta il codice e di' alla persona cosa può fare:
const { error } = await supabase.from('tasks').insert(task)
if (error?.code === '23502') {
setMessage('Scegli una categoria prima di salvare.')
} else if (error) {
setMessage('Qualcosa è andato storto. Riprova.')
}
Spostare una chiave segreta in una Edge Function copre la parte della funzione, se la chiave segreta è ancora nella tua app.
Prima di cambiare le regole di una colonna
Tieni una copia del database da prima di aggiungere un valore predefinito, togliere una regola o scrivere un valore su tutte le righe, e vieni a saperlo quando una tabella comincia a rispondere agli sconosciuti.
Reeve Care conserva una copia del tuo database Supabase:
- una copia cifrata secondo un calendario, sullo spazio di archiviazione di Reeve nell'UE, fuori dal tuo account Supabase
- ogni riga com'era prima che un update su tutta la tabella la riscrivesse
- ogni copia verificata prima di contare, contando le righe di ogni tabella
- anche i tuoi file caricati, appena colleghi una credenziale di Storage
- un ripristino con un clic, con una copia di sicurezza dello stato attuale presa prima
- tutto quello che fa Monitor
Reeve Monitor continua a controllare da fuori:
- se la tua app è raggiungibile, controllato ogni minuto
- il controllo di sicurezza completo ogni ora, su un massimo di tre app
- una email il giorno in cui una nuova scansione valuta la tua app peggio della precedente, così una tabella le cui regole sono state disattivate ti raggiunge senza che tu debba andare a guardare
- un report mensile, in linguaggio semplice, di quello che ha visto
Che cosa fare adesso
Cosa fare
- Leggi la colonna e la tabella nel messaggio. È il campo che è arrivato vuoto.
- Controlla se la tua app lascia fuori quel campo, lo manda come
nullo si aspetta un valore predefinito che la colonna non ha. - Per cambiare alcune colonne di una riga che esiste, usa
.update()con un filtro. Un upsert deve portare ogni colonna obbligatoria. - In un inserimento multiplo, manda ogni colonna in ogni riga, oppure passa
{ defaultToNull: false }così valgono i valori predefiniti della tabella. - Tieni la regola su
user_ide dai alla colonna il valore predefinitoauth.uid(). - Dal codice server, metti l'errore grezzo nei log e rispondi al browser con una frase tua.
Comincia dalla colonna nel messaggio, poi dalla riga del tuo codice che costruisce la riga. Se la correzione cambia la tabella stessa, un nuovo valore predefinito o un update su tutte le righe, tieni una copia del database da prima di eseguirla.
FAQ
Che cosa significa null value in column violates not-null constraint?
Significa che la tua app ha provato a salvare una riga senza valore in una colonna che deve sempre averne uno, e Postgres ha rifiutato la riga intera. Non è stato scritto niente. Il messaggio nomina la colonna e la tabella. Il codice è 23502, e da un'app Supabase arriva come HTTP 400.
Perché la colonna è null se ha un valore predefinito?
Perché la richiesta ha nominato la colonna e l'ha mandata vuota. Postgres usa un valore predefinito solo quando la colonna viene lasciata fuori. supabase-js fa proprio questo in un inserimento multiplo: una colonna che una riga ha e un'altra no entra come null in quella che non ce l'ha, a meno che tu non passi defaultToNull: false.
Perché il mio upsert fallisce con un errore not-null se la riga esiste?
Postgres controlla che la nuova riga non abbia colonne obbligatorie vuote prima di cercare la riga esistente da aggiornare. Un upsert che porta solo le colonne da cambiare fallisce su qualsiasi altra colonna obbligatoria. Per una riga che esiste già, usa update con un filtro.
Devo togliere il vincolo NOT NULL?
Solo se la colonna non doveva mai essere obbligatoria, come un numero di telefono che il tuo modulo tratta come facoltativo. Altrimenti l'errore sparisce e cominciano ad arrivare righe senza il valore, che la tua app incontra altrove. Una riga con user_id vuoto non viene mostrata a nessuno. Trova da dove arriva il null, o dai alla colonna un valore predefinito come auth.uid().
È sicuro mostrare questo errore agli utenti?
No. Quando Postgres include la riga di dettaglio, è una copia della riga intera. Una richiesta del browser a una tabella con la Row Level Security attiva non ne riceve nessuna, ma il codice server con la chiave segreta la riceve sempre. Intercetta il codice 23502, mostra una frase scritta da te e tieni l'errore grezzo nei tuoi log.