Automatically execute code on database events in PostgreSQL
Triggers are database functions that execute automatically when specific events occur on a table, such as INSERT, UPDATE, DELETE, or TRUNCATE operations. They’re powerful tools for maintaining data integrity, enforcing business rules, and automating workflows.
Trigger Function: A function that contains the code to execute
Trigger Definition: Specifies when and how to call the function
-- Step 1: Create the trigger functioncreate or replace function trigger_function_name()returns triggerlanguage plpgsqlas $$begin -- Your trigger logic here return new; -- or return old, or return nullend;$$;-- Step 2: Create the triggercreate trigger trigger_name after insert on table_name for each row execute function trigger_function_name();
Execute before the operation. Can modify the data being inserted/updated.
-- Validate and transform data before insertcreate or replace function validate_email()returns triggerlanguage plpgsqlas $$begin -- Convert email to lowercase new.email := lower(trim(new.email)); -- Validate email format if new.email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' then raise exception 'Invalid email format: %', new.email; end if; return new;end;$$;create trigger validate_user_email before insert or update on users for each row execute function validate_email();
Execute after the operation completes. Cannot modify the data.
-- Log changes after they occurcreate or replace function log_price_change()returns triggerlanguage plpgsqlas $$begin insert into price_history (product_id, old_price, new_price, changed_at) values (new.id, old.price, new.price, now()); return new;end;$$;create trigger track_price_changes after update on products for each row when (old.price is distinct from new.price) execute function log_price_change();
-- Set default values or generate IDscreate or replace function set_user_defaults()returns triggerlanguage plpgsqlas $$begin -- Generate username if not provided if new.username is null then new.username := 'user_' || new.id; end if; -- Set default avatar if new.avatar_url is null then new.avatar_url := 'https://example.com/default-avatar.png'; end if; return new;end;$$;create trigger set_defaults before insert on users for each row execute function set_user_defaults();
-- Automatically update timestampscreate or replace function update_timestamp()returns triggerlanguage plpgsqlas $$begin new.updated_at := now(); return new;end;$$;create trigger update_products_timestamp before update on products for each row execute function update_timestamp();
-- Soft delete implementationcreate or replace function soft_delete()returns triggerlanguage plpgsqlas $$begin -- Instead of deleting, update deleted_at update users set deleted_at = now() where id = old.id; -- Prevent the actual delete return null;end;$$;create trigger soft_delete_users before delete on users for each row execute function soft_delete();
create or replace function audit_changes()returns triggerlanguage plpgsqlas $$begin if TG_OP = 'INSERT' then insert into audit_log (table_name, operation, new_data) values (TG_TABLE_NAME, 'INSERT', row_to_json(new)); return new; elsif TG_OP = 'UPDATE' then insert into audit_log (table_name, operation, old_data, new_data) values (TG_TABLE_NAME, 'UPDATE', row_to_json(old), row_to_json(new)); return new; elsif TG_OP = 'DELETE' then insert into audit_log (table_name, operation, old_data) values (TG_TABLE_NAME, 'DELETE', row_to_json(old)); return old; end if;end;$$;create trigger audit_products after insert or update or delete on products for each row execute function audit_changes();
Here’s a complete example of tracking employee salary changes:
-- Create the salary history tablecreate table salary_log ( id bigint generated always as identity primary key, employee_id bigint not null, old_salary numeric(10,2), new_salary numeric(10,2), changed_at timestamptz default now(), changed_by text);-- Create the trigger functioncreate or replace function update_salary_log()returns triggerlanguage plpgsqlsecurity definerset search_path = publicas $$begin -- Only log if salary actually changed if old.salary is distinct from new.salary then insert into salary_log ( employee_id, old_salary, new_salary, changed_by ) values ( new.id, old.salary, new.salary, current_user ); end if; return new;end;$$;-- Create the triggercreate trigger salary_update_trigger after update on employees for each row execute function update_salary_log();
-- Create a reusable timestamp update functioncreate or replace function utils.update_timestamp()returns triggerset search_path = ''language plpgsqlas $$begin new.updated_at = now(); return new;end;$$;-- Grant execution permissionsgrant execute on function utils.update_timestamp() to anon;grant execute on function utils.update_timestamp() to authenticated;-- Apply to a tablecreate trigger sync_updated_at_content_service before update on content.service for each row execute function utils.update_timestamp();
create or replace rule soft_delete_content_service as on delete to content.service do instead ( update content.service set deleted_at = now() where id = old.id );
Use WHEN clause to conditionally execute triggers:
-- Only trigger when specific columns changecreate trigger notify_price_change after update on products for each row when (old.price is distinct from new.price) execute function notify_price_update();-- Only trigger for high-value orderscreate trigger verify_large_order before insert on orders for each row when (new.total_amount > 10000) execute function verify_order();
Real example from Slack clone - automatically create user profile:
-- Create user profile when auth user is createdcreate or replace function public.handle_new_user()returns triggerlanguage plpgsqlsecurity definerset search_path = auth, publicas $$declare is_admin boolean;begin -- Insert into public users table insert into public.users (id, username) values (new.id, new.email); -- Check if this is the first user select count(*) = 1 from auth.users into is_admin; -- Assign roles based on email if position('+supaadmin@' in new.email) > 0 then insert into public.user_roles (user_id, role) values (new.id, 'admin'); elsif position('+supamod@' in new.email) > 0 then insert into public.user_roles (user_id, role) values (new.id, 'moderator'); end if; return new;end;$$;-- Trigger on auth.users insertcreate trigger on_auth_user_created after insert on auth.users for each row execute function public.handle_new_user();
-- Disable a specific triggeralter table products disable trigger update_timestamp_trigger;-- Enable it againalter table products enable trigger update_timestamp_trigger;-- Disable all triggers on a tablealter table products disable trigger all;
-- Drop the triggerdrop trigger if exists update_timestamp_trigger on products;-- Drop the function (cascade drops all dependent triggers)drop function if exists update_timestamp() cascade;
-- View all triggers in a schemaselect trigger_name, event_manipulation, event_object_table, action_timingfrom information_schema.triggerswhere trigger_schema = 'public'order by event_object_table, trigger_name;
-- Bad: Can cause infinite loopcreate trigger bad_trigger after update on products for each row execute function update_products(); -- Updates products table!-- Good: Prevent loop with conditionalcreate or replace function update_products_safe()returns trigger as $$begin if new.updated_at = old.updated_at then new.updated_at := now(); end if; return new;end;$$ language plpgsql;