Основи безпеки
Помилка Supabase "null value in column violates not-null constraint"
Supabase "null value in column violates not-null constraint": обовʼязковий стовпець прийшов порожнім. Звідки взявся null і як це виправити.

Коротко
- Supabase "null value in column violates not-null constraint" (код Postgres 23502, HTTP 400 у вашому застосунку) означає, що рядок прийшов без нічого в стовпці, який завжди мусить мати значення. Повідомлення називає стовпець і таблицю. Нічого не збереглося.
- Порожнє значення береться з поля, яке ваш застосунок ніколи не надсилає, з null, який ваш код надсилає навмисно, або зі стовпця без значення за замовчуванням, на яке ви розраховували. Upsert і пакетна вставка можуть спричинити його на рядку, що виглядає повним.
- Залиште правило й виправте рядок. Із серверного коду ніколи не повертайте сиру помилку в браузер: її рядок "Failing row contains" може показати весь рядок.
Ви натискаєте Зберегти на новому завданні у своєму застосунку, і нічого не зберігається. Сторінка не показує помилки, список лишається, як був. Консоль браузера знає причину:
{
"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" означає, що рядок
дійшов до вашої бази даних без нічого в стовпці, який завжди мусить мати
значення, і Postgres відхилив увесь рядок. Тут це стовпець category_id у вашій
таблиці tasks. Під час збереження категорію не надіслали.
Найшвидше виправлення полягає в тому, щоб прибрати правило: один рядок SQL, і стовпець може лишатися порожнім. Помилка зникає, а відтоді приходять рядки з порожнім полем, на які решта застосунку натрапляє там, куди ви не дивилися. Спершу зʼясуйте, звідки взялося порожнє значення. Майже завжди це одне з кількох місць.
Що означає "null value in column violates not-null constraint"?
Це означає, що один стовпець рядка, який ваш застосунок хотів зберегти, був порожнім, що цей стовпець ніколи не може бути порожнім, і що Postgres відмовився зберігати будь-яку частину рядка. Нічого не записано. Null у Postgres означає відсутність значення, і це не нуль і не текст без жодної літери.
Уявіть кожен рядок як паперовий бланк, а таблицю як шухляду картотеки, куди він лягає. Обмеження not-null схоже на зірочку, надруковану біля поля. Postgres сидить біля шухляди й не підшиває жодного бланка, де поле із зірочкою порожнє. Бланк повертається, і повідомлення каже, яке саме поле.
| Поле | Приклад | Що воно каже |
|---|---|---|
code | 23502 | Код Postgres для цієї помилки, у його списку кодів він має назву not_null_violation |
message | null value in column "category_id" of relation "tasks" violates not-null constraint | Порожній стовпець, потім таблиця. "Relation" у Postgres означає таблицю |
details | Failing row contains (…) або null | Копія відхиленого рядка, коли Postgres вирішує, що той, хто викликає, може її бачити. Розділ нижче пояснює, коли саме |
hint | null | Для цієї помилки нічого додати |
| Статус HTTP | 400 Bad Request | PostgREST, частина Supabase, яка перетворює виклики supabase.from(...) на SQL, не має окремого випадку для 23502, тож його код повертає 400 |
Формулювання належить самому Postgres, воно з функції, яка породжує помилку, і читається однаково, звідки б не прийшов рядок: із вашого застосунку, з тригера чи з SQL Editor.
Звідки взялося порожнє значення?
Зазвичай з одного з трьох місць у коді вашого власного застосунку, і стовпець у повідомленні підказує, куди дивитися спершу. Ще два починаються всередині бази даних.
Поле, яке ваш застосунок ніколи не надсилає. Це найчастіший випадок. Стовпець
став обовʼязковим уже після того, як форму зробили: конструктор зі ШІ додав
category_id до таблиці, коли ви попросили категорії, позначив його
обовʼязковим і залишив без змін форму, що зберігає завдання. Ніщо в запиті не
називає стовпець, значення за замовчуванням у стовпця немає, тож він порожній.
Порівняйте стовпці у своєму Table Editor з обʼєктом, який ваш застосунок передає в
.insert(). Відсутній якраз той, що в повідомленні.
Ключ, записаний інакше, ніж його стовпець, сюди навіть не доходить. PostgREST
відхиляє categoryId, надісланий у стовпець category_id, раніше, ніж Postgres
побачить рядок, і
ця помилка має окремий допис.
Null, який ваш код надсилає навмисно. { category_id: selected?.id ?? null }
надсилає явний null, коли нічого не вибрано, а явний null для запиту є значенням.
undefined у JavaScript поводиться інакше. У JSON такого значення немає, тож
JSON.stringify
лишає його "omitted (when found in an object)", і ключ зі значенням undefined
до Supabase не доходить узагалі. Від того, що з двох створює ваш код, залежить, чи
зможе спрацювати значення за замовчуванням, і про це наступний розділ.
Стовпець без значення за замовчуванням, на яке ви розраховували. created_at
зазвичай заповнюється сам, бо таблиця каже default now(). user_id зазвичай
ні, бо ніщо в Postgres не знає, хто увійшов, поки стовпцю не скажуть, як це
дізнатися. Розділ далі показує як.
Реєстрація, під іншим повідомленням. Тригер, який копіює кожного нового
користувача в таблицю profiles, читає його реєстраційні дані через ->>, а
JSON-оператори 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". Форма реєстрації, яка ніколи не надсилала full_name, дає тригеру
порожнє імʼя, обовʼязковий стовпець full_name його відхиляє, і Supabase
повідомляє про всю реєстрацію як
"Database error saving new user". Сам
23502 лежить у логах Postgres, і той допис показує, де саме.
Видалення деінде. Зовнішній ключ із on delete set null очищує стовпець у
кожному рядку, що вказував на видалений. Якщо цей стовпець ще й обовʼязковий, з
цим повідомленням падає саме видалення, і
допис про видалення користувачів пояснює,
як вибрати відповідне правило.
"Але ж у стовпця є значення за замовчуванням"
Тоді запит назвав стовпець і надіслав його порожнім. Postgres підставляє значення за замовчуванням лише тоді, коли стовпець повністю пропущено в рядку. Його документація про значення за замовчуванням каже: "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." Явний null вважається вказаним значенням. На бланку поле з надрукованим значенням вважається заповненим, доки хтось не напише поверх друку "немає".
supabase-js пише "немає" поверх в одному несподіваному місці: у пакетній вставці,
коли ви передаєте масив рядків. Він називає кожен стовпець, що зʼявляється бодай
в одному з рядків, а
його вихідний код
описує параметр, який вирішує долю пропусків, defaultToNull: "Make missing
fields default to null. Otherwise, use the default value for the column. Only
applies for bulk inserts." Він дорівнює true, поки ви його не зміните.
Документація PostgREST
каже те саме про запит, який перелічує свої стовпці, а саме такий supabase-js
надсилає для масиву: "any missing columns in the payload will be inserted as
null value by default."
Тож тут друге завдання потрапляє з порожнім status, навіть якщо таблиця дає
status значення за замовчуванням 'todo', і обовʼязковий status відхиляє всю
вставку:
await supabase.from('tasks').insert([
{ title: 'Написати бриф', status: 'doing' },
{ title: 'Надіслати рахунок' },
])
Годиться будь-яке з двох виправлень. Надсилайте status у кожному рядку або
скажіть supabase-js лишити в силі значення таблиці за замовчуванням:
await supabase.from('tasks').insert(rows, { defaultToNull: false })
Цей параметр додає заголовок Prefer: missing=default, який читає PostgREST.
Один рядок, переданий як обʼєкт без масиву довкола, так не робить: ключ, якого в
ньому немає, відсутній і у вставці, і спрацьовує значення за замовчуванням.
Чому upsert падає з цим, хоча рядок уже існує?
Бо Postgres перевіряє новий рядок на порожні обовʼязкові стовпці раніше, ніж шукає наявний рядок, який мав би оновити. Upsert схожий на бланк із позначкою "підшити, а якщо на цю людину вже є справа, змінити". Postgres спершу дивиться на зірочки. Бланк, на якому лише нове фото профілю, повертається, хоча справа, яку він мав змінити, лежить у шухляді.
Порядок записано в
коді, що виконує вставку:
"Check the constraints of the tuple." стоїть перед "Perform a speculative
insertion.", а це той крок, що знаходить конфлікт, на який реагує вираз
on conflict. Tuple у Postgres означає рядок. Тож цей upsert, який мав змінити
один стовпець наявного профілю, падає на будь-якому іншому стовпці, що його
вимагає таблиця:
await supabase.from('profiles').upsert({ id: user.id, avatar_url: url })
Щоб змінити кілька стовпців рядка, який уже є, використовуйте update. Він потрапляє на збережений рядок, у якому заповнено всі обовʼязкові стовпці, і змінює лише те, що ви надсилаєте:
await supabase.from('profiles').update({ avatar_url: url }).eq('id', user.id)
Лишіть upsert для рядків, яких іще може не бути, і надсилайте з ним кожен обовʼязковий стовпець. Допис про дублікати ключів пояснює решту того, що потрібно upsert: за яким стовпцем він звіряє і які правила читання зачіпає з браузера.
Чи варто прибрати правило NOT NULL?
Лише якщо стовпець ніколи не мав бути обовʼязковим. Номер телефону, який ваша форма вважає необовʼязковим, саме такий випадок, і там прибрати правило означає виправити правильно:
alter table public.profiles alter column phone drop not null;
Для будь-якого іншого стовпця залиште правило й заповніть поле. Без нього кожен
рядок, збережений потім, може лишитися без значення, яке правило гарантувало, і
ваш застосунок натрапить на пропуск деінде: сторінка, що читає
task.category.name, зупиниться на Cannot read properties of null, або підрахунок
завдань за категоріями мовчки загубить ті, що без категорії.
Найдорожче прибирати правило зі стовпця user_id. Рядок, збережений із
порожнім власником, ніхто не бачить. Правило читання, записане як
auth.uid() = user_id, ніколи його не охоплює, бо в SQL порожнє значення не
дорівнює нічому, навіть іншому порожньому значенню. Завдання збережене, і відтоді
його не бачить ніхто, навіть людина, яка його створила.
Виправлення для user_id полягає в значенні за замовчуванням, яке підставляє
того, хто увійшов:
alter table public.tasks
alter column user_id set default auth.uid();
Postgres обчислює значення за замовчуванням "whenever the default value is
inserted (not when the table is created)",
його власними словами,
тож кожен новий рядок отримує id людини, яка зберігає. Коли ніхто не увійшов,
auth.uid() повертає null, як попереджає
посібник Supabase з row level security,
і збереження все одно падає. Так правило робить свою роботу. Значення за
замовчуванням заповнює поле лише тоді, коли застосунок його пропускає, тож від
того, щоб хтось надіслав id іншої людини, і далі захищає ваше правило вставки.
Це правило вставки також змінює, яке повідомлення ви бачите. Postgres виконує
перевірки Row Level Security таблиці на новому рядку раніше за перевірки
not-null:
той самий код вставки
ставить "Check any RLS WITH CHECK policies." перед "Check the constraints of the
tuple.". Тож коли ваше правило вставки порівнює user_id з auth.uid(), порожній
user_id спершу не проходить правило й повертається як
new row violates row-level security policy.
23502 на user_id із вашого застосунку означає одне з трьох: правило вставки
ніколи не дивиться на user_id, правила на цій таблиці вимкнені, або запит
прийшов із серверного коду, що їх оминає.
Чи безпечно показувати цю помилку користувачам?
Ні. Показуйте власне речення, а сиру помилку тримайте в логах, бо рядок деталей, коли Postgres його додає, є копією всього рядка.
Рядок деталей дубліката ключа повторює одне значення. Цей повторює весь бланк.
Функція, яка його пише,
виводить рядок як Failing row contains (…) з кожним стовпцем, який той, хто
викликає, має право читати. Для update це рядок таким, яким його було б
збережено, разом зі стовпцями, яких ніхто не торкався. Бланк, який Postgres
повертає, може мати пришпилену до відмови ксерокопію самого себе, і на копії видно
кожне поле.
Postgres вирішує, хто отримає ксерокопію, і його код спершу перевіряє: "Check if
RLS is enabled and should be active for the relation; if so, then don't return
anything." Запит із вашим публічним ключем до таблиці з увімкненою Row Level
Security отримує "details": null, і саме це показувала консоль на початку цієї
сторінки.
Повну копію отримують двоє.
Браузер, коли правила таблиці вимкнені. Якщо консоль показує
Failing row contains для запиту, який ваш застосунок зробив із публічним
ключем, Row Level Security на цій таблиці вимкнена. Так само
допис про дублікати ключів
читає свій рядок деталей, а
увімкнення правил на кожній таблиці
показує перемикач і перше правило. Таблиця з вимкненими правилами віддає свої
рядки незнайомцю так само охоче, як вам. Наше
безкоштовне сканування читає ваш робочий застосунок із
публічним ключем, який отримує кожен відвідувач, і перелічує таблиці, що віддають
рядки. Обліковий запис не потрібен.
Серверний код із секретним ключем. Посібник Supabase каже, що секретний ключ
"authorizes access through the service_role Postgres role, which has the
bypassrls attribute", тож правила ніколи не діють, і рядок деталей завжди
заповнений. Edge Function, яка оновлює рядок і відповідає отриманою помилкою,
return new Response(JSON.stringify(error)), надсилає в браузер кожен стовпець
рядка, який вона змінювала, хоч би кому той рядок належав.
У серверному коді збережіть помилку й відповідайте власними словами:
if (error) {
console.error(error)
return new Response(JSON.stringify({ message: 'Не вдалося зберегти.' }), {
status: 400,
})
}
У застосунку перехопіть код і скажіть людині, що вона може зробити:
const { error } = await supabase.from('tasks').insert(task)
if (error?.code === '23502') {
setMessage('Виберіть категорію, перш ніж зберегти.')
} else if (error) {
setMessage('Щось пішло не так. Спробуйте ще раз.')
}
Перенесення секретного ключа в Edge Function пояснює частину з функцією, якщо секретний ключ досі у вашому застосунку.
Перш ніж змінювати правила стовпця
Зберігайте копію бази даних із часу, коли ви ще не додали значення за замовчуванням, не прибрали правило й не записали значення в усі рядки, і дізнавайтеся, коли таблиця починає відповідати незнайомцям.
Reeve Care зберігає копію вашої бази даних Supabase:
- зашифрована копія за розкладом, у власному сховищі Reeve в ЄС, поза вашим обліковим записом Supabase
- кожен рядок таким, яким він був до того, як update на всю таблицю його переписав
- кожна копія перевірена, перш ніж її зараховують, підрахунком рядків у кожній таблиці
- ваші завантажені файли теж, щойно ви підключите облікові дані Storage
- відновлення одним кліком, перед яким береться страхувальна копія поточного стану
- усе, що робить Monitor
Reeve Monitor і далі перевіряє ззовні:
- чи доступний ваш застосунок, з перевіркою щохвилини
- повна перевірка безпеки щогодини, до трьох застосунків
- лист того дня, коли нове сканування оцінить ваш застосунок гірше за попереднє, тож таблиця, на якій вимкнули правила, дійде до вас, і вам не доведеться йти дивитися
- щомісячний звіт простою мовою про те, що він побачив
Що зробити зараз
Що робити
- Прочитайте стовпець і таблицю в повідомленні. Це те поле, що прийшло порожнім.
- Перевірте, чи ваш застосунок пропускає це поле, надсилає його як
nullабо чекає значення за замовчуванням, якого в стовпця немає. - Щоб змінити кілька стовпців наявного рядка, використовуйте
.update()з фільтром. Upsert мусить нести кожен обовʼязковий стовпець. - У пакетній вставці надсилайте кожен стовпець у кожному рядку або передайте
{ defaultToNull: false }, щоб діяли значення таблиці за замовчуванням. - Залиште правило на
user_idі дайте стовпцю значення за замовчуваннямauth.uid(). - У серверному коді записуйте сиру помилку в лог, а браузеру відповідайте власним реченням.
Почніть зі стовпця в повідомленні, потім перейдіть до рядка свого коду, який складає рядок. Якщо виправлення змінює саму таблицю, нове значення за замовчуванням чи update на всі рядки, збережіть копію бази даних до того, як його запустите.
Поширені запитання
Що означає null value in column violates not-null constraint?
Ваш застосунок спробував зберегти рядок без значення в стовпці, який завжди мусить його мати, і Postgres відхилив увесь рядок. Нічого не записано. Повідомлення називає стовпець і таблицю. Код 23502, а із застосунку на Supabase він приходить як HTTP 400.
Чому стовпець null, якщо в нього є значення за замовчуванням?
Бо запит назвав стовпець і надіслав його порожнім. Postgres підставляє значення за замовчуванням лише тоді, коли стовпець пропущено. supabase-js робить саме це в пакетній вставці: стовпець, який є в одному рядку й відсутній в іншому, потрапляє в другий як null, якщо ви не передасте defaultToNull: false.
Чому мій upsert падає з помилкою not-null, хоча рядок існує?
Postgres перевіряє новий рядок на порожні обовʼязкові стовпці раніше, ніж шукає наявний рядок, який мав би оновити. Upsert, що несе лише стовпці для зміни, падає на будь-якому іншому обовʼязковому стовпці. Для рядка, який уже існує, використовуйте update з фільтром.
Чи варто прибрати обмеження NOT NULL?
Лише якщо стовпець ніколи не мав бути обовʼязковим, як-от номер телефону, який ваша форма вважає необовʼязковим. Інакше помилка зникне, а рядки почнуть приходити без значення, на яке ваш застосунок натрапить деінде. Рядок із порожнім user_id не бачить ніхто. Зʼясуйте, звідки береться null, або дайте стовпцю значення за замовчуванням, наприклад auth.uid().
Чи безпечно показувати цю помилку користувачам?
Ні. Коли Postgres додає рядок деталей, це копія всього рядка. Запит із браузера до таблиці з увімкненою Row Level Security його не отримує, а серверний код із секретним ключем отримує завжди. Перехоплюйте код 23502, показуйте речення, яке написали ви, а сиру помилку тримайте у своїх логах.