NEW is null in a trigger function
Why NEW (or a field of it) is null inside a Postgres trigger. Covers statement-level triggers, DELETE triggers, wrong return values and missing user metadata in sign-up triggers.
What you see
One of these inside a trigger function:
ERROR: 55000: record "new" is not assigned yet
DETAIL: The tuple structure of a not-yet-assigned record is indeterminate.
ERROR: 23502: null value in column "message_id" of relation "audit_log" violates not-null constraint
Or no error at all: rows are silently not inserted, or a column the trigger was meant to fill stays empty.
Why it happens
NEW holds the row being written, but only in some kinds of trigger:
| Trigger | NEW |
OLD |
|---|---|---|
for each row, INSERT |
the new row | null |
for each row, UPDATE |
the row after the change | the row before |
for each row, DELETE |
null | the deleted row |
for each statement |
null | null |
So the usual causes are:
- The trigger is
for each statement. It fires once per statement with no row.for each statementis the default if you leave it out. - The trigger also fires on DELETE and the function reads
NEWunconditionally. - A
beforetrigger returns the wrong thing. Returningnullfrom a row-levelbeforetrigger cancels the write for that row with no error. On DELETE, returnOLD. - The row is fine but the field is empty. A sign-up trigger reads
NEW.raw_user_meta_data ->> 'display_name', but the app never sent that key.
How to fix it
Check how the trigger is defined:
select tgname, pg_get_triggerdef(oid) from pg_trigger where tgrelid = 'public.messages'::regclass and not tgisinternal;If it says
FOR EACH STATEMENT, recreate it per row:drop trigger if exists messages_audit on public.messages; create trigger messages_audit after insert or update or delete on public.messages for each row execute function public.audit_message();Branch on
TG_OPand use the right record:create or replace function public.audit_message() returns trigger language plpgsql set search_path = '' as $$ begin if tg_op = 'DELETE' then insert into public.audit_log (message_id, action) values (old.id, tg_op); return old; end if; insert into public.audit_log (message_id, action) values (new.id, tg_op); return new; end; $$;In an
aftertrigger the return value is ignored, but returning the record keeps the function safe to reuse as abeforetrigger.For sign-up triggers on
auth.users, send the metadata when signing up and give it a fallback:await supabase.auth.signUp({ email, password, options: { data: { display_name: 'Sam Rivera' } }, })insert into public.profiles (id, display_name) values ( new.id, coalesce(new.raw_user_meta_data ->> 'display_name', split_part(new.email, '@', 1)) );OAuth providers fill metadata with their own keys (often
full_name,nameoravatar_url), so look at a real user'sraw_user_meta_datain Studio before relying on a key.Debug with
raise logorraise notice:raise notice 'op=%, new=%, old=%', tg_op, new, old;Notices show in Studio's SQL editor when you fire the trigger there.
On SupaHost
Studio's Logs pages aren't available, so raise log output from triggers fired by your app isn't searchable in the dashboard. Reproduce the write in Studio's SQL editor (Dashboard → your project → Database tab → Open Studio) and use raise notice to see the values.