Skip to main content
RapidDev - Software Development Agency
supabase-tutorial

How to Use Triggers in Supabase

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.

What you'll learn

  • How to create trigger functions in PL/pgSQL
  • How to attach BEFORE and AFTER triggers to tables
  • How to implement the canonical handle_new_user trigger pattern
  • How to debug trigger failures using Supabase logs
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner9 min read10-15 minSupabase (all plans), PostgreSQL 14+March 2026RapidDev Engineering Team
TL;DR

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

1

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.

typescript
1-- Simple trigger function: auto-set updated_at on UPDATE
2create or replace function public.set_updated_at()
3returns trigger
4language plpgsql
5as $$
6begin
7 new.updated_at = now();
8 return new;
9end;
10$$;

Expected result: The trigger function is created and ready to be attached to a table.

2

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.

typescript
1-- Create a table with timestamps
2create 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);
10
11-- Enable RLS on the table
12alter table public.posts enable row level security;
13
14-- Attach the trigger: auto-update updated_at on every UPDATE
15create trigger set_posts_updated_at
16 before update on public.posts
17 for each row
18 execute function public.set_updated_at();
19
20-- RLS policy: users can manage their own posts
21create policy "Users manage own posts" on public.posts
22 for all to authenticated
23 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.

3

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.

typescript
1-- Create the profiles table
2create 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);
9
10alter table public.profiles enable row level security;
11
12create policy "Public profiles are viewable by everyone"
13 on public.profiles for select
14 to anon, authenticated
15 using (true);
16
17create policy "Users can update own profile"
18 on public.profiles for update
19 to authenticated
20 using (auth.uid() = id)
21 with check (auth.uid() = id);
22
23-- Create the trigger function
24create or replace function public.handle_new_user()
25returns trigger
26language plpgsql
27security definer set search_path = ''
28as $$
29begin
30 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$$;
40
41-- Attach to auth.users
42create trigger on_auth_user_created
43 after insert on auth.users
44 for each row
45 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.

4

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.

typescript
1-- Validate that post titles are not empty and not too long
2create or replace function public.validate_post()
3returns trigger
4language plpgsql
5as $$
6begin
7 if length(trim(new.title)) = 0 then
8 raise exception 'Post title cannot be empty';
9 end if;
10
11 if length(new.title) > 200 then
12 raise exception 'Post title cannot exceed 200 characters';
13 end if;
14
15 -- Sanitize: trim whitespace
16 new.title = trim(new.title);
17 return new;
18end;
19$$;
20
21create trigger validate_post_before_insert
22 before insert or update on public.posts
23 for each row
24 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.

5

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.

typescript
1-- Add debug logging to a trigger function
2create or replace function public.handle_new_user()
3returns trigger
4language plpgsql
5security definer set search_path = ''
6as $$
7begin
8 raise notice 'Creating profile for user: %, email: %', new.id, new.email;
9
10 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 );
17
18 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

triggers-setup.sql
1-- ============================================
2-- Supabase Triggers Setup
3-- ============================================
4
5-- 1. Reusable trigger function: auto-set updated_at
6create or replace function public.set_updated_at()
7returns trigger
8language plpgsql
9as $$
10begin
11 new.updated_at = now();
12 return new;
13end;
14$$;
15
16-- 2. Profiles table linked to auth.users
17create 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);
24
25alter table public.profiles enable row level security;
26
27create policy "Public profiles viewable" on public.profiles
28 for select to anon, authenticated using (true);
29create policy "Users update own profile" on public.profiles
30 for update to authenticated
31 using (auth.uid() = id) with check (auth.uid() = id);
32
33-- 3. Auto-create profile on user signup
34create or replace function public.handle_new_user()
35returns trigger
36language plpgsql
37security definer set search_path = ''
38as $$
39begin
40 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$$;
50
51create trigger on_auth_user_created
52 after insert on auth.users
53 for each row execute function public.handle_new_user();
54
55-- 4. Posts table with auto-updating timestamp
56create 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);
64
65alter table public.posts enable row level security;
66
67create policy "Users manage own posts" on public.posts
68 for all to authenticated
69 using (auth.uid() = user_id)
70 with check (auth.uid() = user_id);
71
72create trigger set_posts_updated_at
73 before update on public.posts
74 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.

ChatGPT Prompt

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.

Supabase Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.