Skip to content

Security basics

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

Supabase "null value in column violates not-null constraint": a required column arrived empty. Where the null came from, and the fix.

Vlad Tkachenko13 min read
A paper form held in front of an open filing drawer, its second starred box empty and outlined in amber.

In short

  • Supabase "null value in column violates not-null constraint" (Postgres code 23502, HTTP 400 in your app) means a row arrived with nothing in a column that must always hold a value. The message names the column and the table. Nothing was saved.
  • The empty value comes from a field your app never sends, a null your code sends on purpose, or a column with no default where you assumed one. An upsert and a bulk insert can each produce it on a row that looks complete.
  • Keep the rule and fix the row. From server code, never pass the raw error back to the browser: its "Failing row contains" line can print the whole row.

You press Save on a new task in your app and nothing is saved. The page shows no error and the list stays as it was. The browser console has the reason:

{
  "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" means a row reached your database with nothing in a column that must always hold a value, and Postgres refused the whole row. Here the column is category_id in your tasks table. The save sent no category.

The quickest fix is to remove the rule: one line of SQL that lets the column stay empty. The error goes away, and rows start arriving with that box blank, which the rest of your app then meets somewhere you were not looking. Find out where the empty value came from first. It is nearly always one of a few places.

What does "null value in column violates not-null constraint" mean?

It means one column of the row your app tried to save was empty, that column is marked as one that may never be empty, and Postgres refused to save any of the row. Nothing was written. Null is Postgres's word for no value at all, which is different from a zero or from text with no letters in it.

Think of each row as a paper form and the table as the filing drawer it goes into. A not-null constraint is a star printed beside one box. Postgres is the clerk at the drawer, and the clerk files nothing with a starred box left blank. The form comes back, and the message says which box.

FieldExampleWhat it tells you
code23502Postgres's code for this error, named not_null_violation in its list of codes
messagenull value in column "category_id" of relation "tasks" violates not-null constraintThe empty column, then the table. "Relation" is Postgres's word for a table
detailsFailing row contains (…), or nullA copy of the refused row, when Postgres decides the caller may see it. A section further down covers when that is
hintnullNothing to add for this error
HTTP status400 Bad RequestPostgREST, the part of Supabase that turns supabase.from(...) calls into SQL, has no special case for 23502, so its code falls through to 400

The wording is Postgres's own, from the function that raises it, and it reads the same whether the row came from your app, a trigger or the SQL Editor.

Where did the empty value come from?

Usually from one of three places in your app's own code, and the column in the message tells you which to look at first. Two more start inside the database.

A field your app never sends. This is the commonest. The column became required after the form was built: an AI builder added category_id to the table when you asked for categories, marked it required, and left the form that saves tasks as it was. Nothing in the request names the column, the column has no default, so it is empty. Compare the columns in your Table Editor with the object your app passes to .insert(). The missing one is the one in the message.

A key spelled differently from its column never gets this far. PostgREST refuses categoryId sent to a column called category_id before Postgres sees the row, and that error has a post of its own.

A null your code sends on purpose. { category_id: selected?.id ?? null } sends an explicit null when nothing is selected, and an explicit null is a value as far as the request is concerned. JavaScript's undefined behaves differently. JSON has no such value, so JSON.stringify leaves it "omitted (when found in an object)", and a key set to undefined never reaches Supabase at all. Which of the two your code produces decides whether a default can step in, and the next section is about that.

A column with no default where you assumed one. created_at usually fills itself in, because the table says default now(). user_id usually does not, because nothing in Postgres knows who is signed in unless the column is told how to find out. A later section shows how.

A sign-up, under another message. A trigger that copies each new user into a profiles table reads their sign-up details with ->>, and Postgres's JSON operators "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". A sign-up form that never sent full_name hands the trigger an empty name, a required full_name column refuses it, and Supabase reports the whole sign-up as "Database error saving new user". The 23502 itself is in the Postgres logs, and that post shows where.

A delete somewhere else. A foreign key set to on delete set null empties the column in every row that pointed at the deleted one. If that column is also required, the delete fails with this message instead, and the post on deleting users covers choosing a rule that fits.

"But the column has a default"

Then the request named the column and sent it empty. Postgres fills in a default only when a column is left out of the row entirely. Its documentation on defaults says: "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." An explicit null counts as a value specified. On the form, a box with a value printed in it counts as filled until somebody writes "none" over the print.

supabase-js writes "none" over it in one place you would not expect: a bulk insert, where you pass an array of rows. It names every column that appears in any of the rows, and its source documents the option that decides what happens to the gaps, defaultToNull: "Make missing fields default to null. Otherwise, use the default value for the column. Only applies for bulk inserts." It is true unless you change it. PostgREST's documentation says the same for a request that lists its columns, which is what supabase-js sends for an array: "any missing columns in the payload will be inserted as null value by default."

So here the second task goes in with status empty, even if the table says status defaults to 'todo', and a required status refuses the whole insert:

await supabase.from('tasks').insert([
  { title: 'Write the brief', status: 'doing' },
  { title: 'Send the invoice' },
])

Either repair works. Send status in every row, or tell supabase-js to let the table's defaults stand:

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

That option adds the Prefer: missing=default header PostgREST reads. A single row passed as an object, without the array around it, does not do this: a key left out of it is left out of the insert, and the default applies.

In a bulk insert, a column one row has and another lacks goes in as null for the row that lacks it. defaultToNull: false lets the table's default fill it instead.

Why does an upsert fail with this when the row already exists?

Because Postgres checks the new row for empty required columns before it looks for the existing row it would update. An upsert is a form marked "file this, or if there is already a file for this person, change it". The clerk checks the stars first. A form carrying only a new profile picture comes back, even though the file it was meant to change is sitting in the drawer.

The order is written into the code that runs an insert: "Check the constraints of the tuple." comes before "Perform a speculative insertion.", which is the step that finds the clash an on conflict clause acts on. Tuple is Postgres's word for a row. So this upsert, meant to change one column of a profile that exists, fails on any other column the table requires:

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

To change some columns of a row that is already there, use an update. It lands on the stored row, which has every required column filled, and changes only what you send:

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

Keep upsert for rows that might not exist yet, and send every required column with it. The post on duplicate keys covers the rest of what an upsert needs: which column it matches on, and the read rules it calls on from the browser.

An upsert's row is checked for empty required columns before Postgres looks for the row it would update. An update starts from the stored row, which already has them.

Should you drop the NOT NULL rule?

Only if the column was never meant to be required. A phone number your form treats as optional is that case, and dropping the rule there is the right repair:

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

For any other column, keep the rule and fill the box. Without it, every row saved afterwards can lack the value the rule was guaranteeing, and your app meets the gap somewhere else: a page that reads task.category.name stops with Cannot read properties of null, or a count of tasks per category quietly loses the ones with none.

The column where dropping it costs most is user_id. A row saved with an empty owner is shown to nobody. A read rule written as auth.uid() = user_id never matches it, because in SQL an empty value equals nothing, another empty value included. The task is saved, and from then on nobody sees it, the person who created it included.

The repair for user_id is a default that fills in whoever is signed in:

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

Postgres evaluates a default "whenever the default value is inserted (not when the table is created)", in its own words, so each new row gets the id of the person saving it. With nobody signed in, auth.uid() returns null, as Supabase's guide to row level security warns, and the save still fails. That is the rule doing its job. The default fills the box only when your app leaves it out, so what stops somebody sending another person's id is still your insert rule.

That insert rule also changes which message you see. Postgres runs a table's Row Level Security checks on a new row before its not-null checks: the same insert code has "Check any RLS WITH CHECK policies." ahead of "Check the constraints of the tuple.". So when your insert rule compares user_id with auth.uid(), an empty user_id fails the rule first and comes back as new row violates row-level security policy. A 23502 on user_id from your app means one of three things: the insert rule never looks at user_id, the rules are off on that table, or the request came from server code that skips them.

Is it safe to show this error to your users?

No. Show a sentence of your own and keep the raw error in your logs, because the detail line, when Postgres includes it, is a copy of the whole row.

A duplicate key's detail line repeats one value. This one repeats the form. The function that writes it prints the row as Failing row contains (…), with every column the caller is allowed to read. On an update, that is the row as it would have been saved, including the columns nobody touched. A form the clerk sends back can come with a photocopy of itself stapled to the refusal, and the copy shows every box.

Postgres decides who gets the photocopy, and its code checks first: "Check if RLS is enabled and should be active for the relation; if so, then don't return anything." A request made with your public key to a table with Row Level Security on gets "details": null, which is what the console at the top of this page showed.

Two callers get the full copy.

A browser, when the table's rules are off. If the console shows Failing row contains for a request your app made with the public key, Row Level Security is off on that table. That is the same reading the duplicate-key post gives its own detail line, and turning the rules on for every table covers the switch and the first rule. A table with its rules off hands rows to a stranger as readily as to you. Our free scan reads your live app with the public key any visitor gets and lists the tables that hand rows back. It needs no account.

Server code holding the secret key. Supabase's guide says the secret key "authorizes access through the service_role Postgres role, which has the bypassrls attribute", so the rules never apply and the detail line is always filled in. An Edge Function that updates a row and answers with the error it got, return new Response(JSON.stringify(error)), sends the browser every column of the row it was changing, whoever that row belongs to.

In server code, keep the error and answer with your own words:

if (error) {
  console.error(error)
  return new Response(JSON.stringify({ message: 'That could not be saved.' }), {
    status: 400,
  })
}

In the app, catch the code and say what the person can do about it:

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

if (error?.code === '23502') {
  setMessage('Pick a category before you save.')
} else if (error) {
  setMessage('Something went wrong. Please try again.')
}

Moving a secret key into an Edge Function covers the function half of this, if the secret key is still in your app.

With Row Level Security on, the browser gets no copy of the row. Server code holding the secret key gets all of it, and passes it on if it returns the error as it came.

Before you change a column's rules

Keep a copy of the database from before you add a default, drop a rule or fill in a value across every row, and hear about it when a table starts answering strangers.

Reeve Care keeps a copy of your Supabase database:

  • an encrypted copy on a schedule, on Reeve's own storage in the EU, outside your Supabase account
  • every row as it was before an update across the whole table rewrote it
  • each copy verified before it counts, by counting the rows in every table
  • your uploaded files as well, once you connect a Storage credential
  • a one-click restore, with a safety copy of the current state taken first
  • everything Monitor does

Reeve Monitor keeps checking from outside:

  • whether your app is up, checked every minute
  • the full security check every hour, on up to three apps
  • an email the day a re-scan grades your app lower than the one before, so a table whose rules were switched off reaches you without you going to look
  • a monthly report, in plain language, of what it saw

What to do now

What to do

  • Read the column and the table in the message. That is the box that arrived empty.
  • Check whether your app leaves that field out, sends it as null, or expects a default the column does not have.
  • To change some columns of a row that exists, use .update() with a filter. An upsert has to carry every required column.
  • In a bulk insert, send every column in every row, or pass { defaultToNull: false } so the table's defaults stand.
  • Keep the rule on user_id and give the column the default auth.uid().
  • From server code, log the raw error and answer the browser with a sentence of your own.

Start with the column in the message, then the line of your code that builds the row. If the repair changes the table itself, a new default or an update across every row, keep a copy of the database from before you run it.

FAQ

What does null value in column violates not-null constraint mean?

It means your app tried to save a row with no value in a column that must always have one, and Postgres refused the whole row. Nothing was written. The message names the column and the table. The code is 23502, and from a Supabase app it arrives as HTTP 400.

Why is the column null when it has a default?

Because the request named the column and sent it empty. Postgres fills in a default only when the column is left out. supabase-js does exactly this in a bulk insert: a column that one row has and another lacks goes in as null for the row that lacks it, unless you pass defaultToNull: false.

Why does my upsert fail with a not-null error when the row exists?

Postgres checks the new row for empty required columns before it looks for the existing row to update. An upsert carrying only the columns you want to change fails on any other required column. For a row that already exists, use update with a filter.

Should I drop the NOT NULL constraint?

Only if the column was never meant to be required, such as a phone number your form treats as optional. Otherwise the error stops and rows start arriving without the value, which your app meets somewhere else. A row with an empty user_id is shown to nobody. Find where the null comes from, or give the column a default such as auth.uid().

Is it safe to show this error to users?

No. When Postgres includes the detail line, it is a copy of the whole row. A browser request to a table with Row Level Security on gets none, but server code using the secret key always does. Catch code 23502, show a sentence you wrote, and keep the raw error in your logs.

Written by

Vlad Tkachenko

Founder, Reeve

I spend my time looking at apps built with Lovable, Bolt, v0, Cursor and Replit, and at the short list of mistakes that keep turning up in them.

More about the author

Read next

All articles

Back up your Supabase database before your next change

Reeve keeps an encrypted copy on a schedule and restores it for you if something breaks.

See Supabase backups

Automated external check, not a full audit. Absence of findings is not a guarantee of safety.