To use triggers in Supabase, create a PL/pgSQL function that returns trigger, then attach it to a table with CREATE TRIGGER specifying BEFORE or AFTER and the event type (INSERT, UPDATE, DELETE). The most common pattern is auto-creating a user profile when a new auth.users row is inserted. Triggers run inside the same transaction as the original operation, so they are guaranteed to execute or roll back together.
Creating and Using Database Triggers in Supabase
PostgreSQL triggers are functions that execute automatically when a specified event (INSERT, UPDATE, DELETE) occurs on a table. In Supabase, triggers are commonly used to auto-create user profiles on signup, update timestamps, validate data before writes, and sync data between tables. This tutorial covers creating trigger functions, attaching them to tables, understanding BEFORE vs AFTER triggers, and debugging common trigger issues.
Prerequisites
- A Supabase project (free tier or above)
- Access to the SQL Editor in Supabase Dashboard
- Basic knowledge of SQL and PL/pgSQL
- A table to attach the trigger to (we will create one)
Step-by-step guide
Create a trigger function
Create a trigger function
A trigger function is a regular PL/pgSQL function that returns the trigger type. Inside the function, you have access to special variables: NEW (the row being inserted or updated), OLD (the row before update or being deleted), and TG_OP (the operation type: INSERT, UPDATE, or DELETE). The function must return NEW for INSERT/UPDATE triggers or OLD for DELETE triggers. Returning NULL from a BEFORE trigger cancels the operation.
1-- Simple trigger function: auto-set updated_at on UPDATE2create or replace function public.set_updated_at()3returns trigger4language plpgsql5as $$6begin7 new.updated_at = now();8 return new;9end;10$$;Expected result: The trigger function is created and ready to be attached to a table.
Attach the trigger to a table
Attach the trigger to a table
Use CREATE TRIGGER to attach your function to a table. Specify BEFORE or AFTER to control when the function runs relative to the operation. BEFORE triggers can modify the row (e.g., set default values) or cancel the operation (by returning NULL). AFTER triggers run after the row is written and are used for side effects like logging or syncing data to other tables. FOR EACH ROW means the function runs once per affected row.
1-- Create a table with timestamps2create table public.posts (3 id uuid primary key default gen_random_uuid(),4 user_id uuid references auth.users(id) on delete cascade,5 title text not null,6 content text,7 created_at timestamptz default now(),8 updated_at timestamptz default now()9);1011-- Enable RLS on the table12alter table public.posts enable row level security;1314-- Attach the trigger: auto-update updated_at on every UPDATE15create trigger set_posts_updated_at16 before update on public.posts17 for each row18 execute function public.set_updated_at();1920-- RLS policy: users can manage their own posts21create policy "Users manage own posts" on public.posts22 for all to authenticated23 using (auth.uid() = user_id)24 with check (auth.uid() = user_id);Expected result: Every time a row in the posts table is updated, the updated_at column is automatically set to the current timestamp.
Implement the handle_new_user trigger pattern
Implement the handle_new_user trigger pattern
The most common Supabase trigger pattern is auto-creating a profile row when a new user signs up. This trigger fires AFTER INSERT on auth.users and inserts a row into your public profiles table. Use security definer so the function can write to the profiles table regardless of the calling user's permissions. Always set search_path to empty string when using security definer.
1-- Create the profiles table2create table public.profiles (3 id uuid primary key references auth.users(id) on delete cascade,4 email text,5 full_name text,6 avatar_url text,7 created_at timestamptz default now()8);910alter table public.profiles enable row level security;1112create policy "Public profiles are viewable by everyone"13 on public.profiles for select14 to anon, authenticated15 using (true);1617create policy "Users can update own profile"18 on public.profiles for update19 to authenticated20 using (auth.uid() = id)21 with check (auth.uid() = id);2223-- Create the trigger function24create or replace function public.handle_new_user()25returns trigger26language plpgsql27security definer set search_path = ''28as $$29begin30 insert into public.profiles (id, email, full_name, avatar_url)31 values (32 new.id,33 new.email,34 new.raw_user_meta_data ->> 'full_name',35 new.raw_user_meta_data ->> 'avatar_url'36 );37 return new;38end;39$$;4041-- Attach to auth.users42create trigger on_auth_user_created43 after insert on auth.users44 for each row45 execute function public.handle_new_user();Expected result: Every new user signup automatically creates a corresponding row in the profiles table with their email and metadata.
Create a validation trigger with BEFORE INSERT
Create a validation trigger with BEFORE INSERT
BEFORE triggers can validate data and reject invalid rows by raising an exception. This is useful for enforcing business rules that cannot be expressed with CHECK constraints or RLS policies. When a BEFORE trigger raises an exception, the entire operation is rolled back.
1-- Validate that post titles are not empty and not too long2create or replace function public.validate_post()3returns trigger4language plpgsql5as $$6begin7 if length(trim(new.title)) = 0 then8 raise exception 'Post title cannot be empty';9 end if;1011 if length(new.title) > 200 then12 raise exception 'Post title cannot exceed 200 characters';13 end if;1415 -- Sanitize: trim whitespace16 new.title = trim(new.title);17 return new;18end;19$$;2021create trigger validate_post_before_insert22 before insert or update on public.posts23 for each row24 execute function public.validate_post();Expected result: Attempting to insert a post with an empty or overly long title returns an error, and the row is not inserted.
Debug trigger failures using logs
Debug trigger failures using logs
When a trigger fails, the error may not be immediately visible. In the Supabase Dashboard, go to Logs in the left sidebar and check the Postgres logs for trigger-related errors. Common issues include: the trigger function references a column that does not exist, the profiles table has a NOT NULL constraint that is violated, or the security definer function does not have permission to write to the target table. Use RAISE NOTICE for debugging output.
1-- Add debug logging to a trigger function2create or replace function public.handle_new_user()3returns trigger4language plpgsql5security definer set search_path = ''6as $$7begin8 raise notice 'Creating profile for user: %, email: %', new.id, new.email;910 insert into public.profiles (id, email, full_name, avatar_url)11 values (12 new.id,13 new.email,14 new.raw_user_meta_data ->> 'full_name',15 new.raw_user_meta_data ->> 'avatar_url'16 );1718 raise notice 'Profile created successfully for user: %', new.id;19 return new;20end;21$$;Expected result: Trigger execution details appear in the Postgres logs, making it easy to identify where failures occur.
Complete working example
1-- ============================================2-- Supabase Triggers Setup3-- ============================================45-- 1. Reusable trigger function: auto-set updated_at6create or replace function public.set_updated_at()7returns trigger8language plpgsql9as $$10begin11 new.updated_at = now();12 return new;13end;14$$;1516-- 2. Profiles table linked to auth.users17create table if not exists public.profiles (18 id uuid primary key references auth.users(id) on delete cascade,19 email text,20 full_name text,21 avatar_url text,22 created_at timestamptz default now()23);2425alter table public.profiles enable row level security;2627create policy "Public profiles viewable" on public.profiles28 for select to anon, authenticated using (true);29create policy "Users update own profile" on public.profiles30 for update to authenticated31 using (auth.uid() = id) with check (auth.uid() = id);3233-- 3. Auto-create profile on user signup34create or replace function public.handle_new_user()35returns trigger36language plpgsql37security definer set search_path = ''38as $$39begin40 insert into public.profiles (id, email, full_name, avatar_url)41 values (42 new.id,43 new.email,44 new.raw_user_meta_data ->> 'full_name',45 new.raw_user_meta_data ->> 'avatar_url'46 );47 return new;48end;49$$;5051create trigger on_auth_user_created52 after insert on auth.users53 for each row execute function public.handle_new_user();5455-- 4. Posts table with auto-updating timestamp56create table if not exists public.posts (57 id uuid primary key default gen_random_uuid(),58 user_id uuid references auth.users(id) on delete cascade,59 title text not null,60 content text,61 created_at timestamptz default now(),62 updated_at timestamptz default now()63);6465alter table public.posts enable row level security;6667create policy "Users manage own posts" on public.posts68 for all to authenticated69 using (auth.uid() = user_id)70 with check (auth.uid() = user_id);7172create trigger set_posts_updated_at73 before update on public.posts74 for each row execute function public.set_updated_at();Common mistakes when using Triggers in Supabase
Why it's a problem: Forgetting to set search_path = '' when using security definer in trigger functions
How to avoid: Always add 'security definer set search_path = ''' to functions that use security definer. Without it, a malicious user could potentially exploit the search path to call unintended functions.
Why it's a problem: Using a BEFORE trigger to insert into another table, which fails because the source row does not exist yet
How to avoid: Use AFTER triggers for side effects that reference the newly inserted row. BEFORE triggers should only modify the current row (via NEW) or validate data.
Why it's a problem: Returning NULL from a BEFORE INSERT trigger, which silently cancels the insert
How to avoid: Always return NEW from BEFORE INSERT and BEFORE UPDATE triggers unless you intentionally want to cancel the operation. Return OLD from BEFORE DELETE triggers.
Why it's a problem: Creating recursive triggers that call each other in an infinite loop
How to avoid: If a trigger function modifies the same table it is attached to, add a condition to prevent recursion, or use pg_trigger_depth() to detect re-entry: IF pg_trigger_depth() > 1 THEN RETURN NEW; END IF;
Best practices
- Use BEFORE triggers for data validation and modification, AFTER triggers for side effects and logging
- Always use security definer with set search_path = '' when the trigger function needs to access restricted schemas like auth
- Return NEW from INSERT/UPDATE trigger functions and OLD from DELETE trigger functions
- Keep trigger functions simple and fast — they run inside the transaction and block the original query
- Create reusable trigger functions (like set_updated_at) that can be attached to multiple tables
- Add RLS policies on any tables that trigger functions write to, even if the trigger uses security definer
- Use RAISE NOTICE for temporary debugging output, visible in the Dashboard Postgres logs
- Document your triggers with comments explaining what they do and why
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I need to set up PostgreSQL triggers in Supabase. Create a trigger that auto-creates a profile in the profiles table when a new user signs up in auth.users, and another trigger that auto-updates the updated_at column on my posts table. Show me the full SQL.
Create triggers for my Supabase project: (1) auto-create a profile row on user signup with data from raw_user_meta_data, (2) auto-set updated_at on posts table updates, and (3) validate that post titles are between 1 and 200 characters. Include RLS policies for both tables.
Frequently asked questions
What is the difference between BEFORE and AFTER triggers?
BEFORE triggers run before the row is written to the table. They can modify the row (change NEW values) or cancel the operation (return NULL). AFTER triggers run after the row is committed and are used for side effects like inserting into other tables or sending notifications.
Can I have multiple triggers on the same table?
Yes, you can have multiple triggers on the same table and same event. They execute in alphabetical order by trigger name. Use naming conventions to control execution order if needed.
How do I remove a trigger?
Use DROP TRIGGER trigger_name ON table_name. For example: DROP TRIGGER set_posts_updated_at ON public.posts; This removes the trigger but keeps the function.
Can triggers access the authenticated user's ID?
Yes, use auth.uid() inside the trigger function to get the current user's UUID. This works because the Supabase client sends the JWT with every request, and PostgreSQL makes it available via the auth.uid() helper.
Why does my handle_new_user trigger fail silently?
Check the Postgres logs in the Dashboard under Logs. Common causes: the profiles table has a NOT NULL column that is not being set, the trigger function has a syntax error, or the function does not use security definer and lacks permission to insert into the profiles table.
Do triggers work with RLS?
Triggers run in the context of the calling user unless the function uses security definer. With security definer, the function runs as the function owner (usually postgres) and bypasses RLS. This is why handle_new_user uses security definer — the signing-up user does not yet have permission to insert into profiles.
Can RapidDev help set up database triggers for my Supabase project?
Yes, RapidDev can design and implement trigger-based automation for your Supabase database, including user profile creation, data validation, audit logging, and cross-table synchronization.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation