Introduction
Triggers shine in recurring patterns: audit trails, data validation, denormalized counters, and change prevention. Rather than reinventing these each time, learning the standard patterns lets you implement them quickly and correctly. This lesson covers the most common real-world trigger patterns with production-ready examples.
Key Concepts
- Audit trigger: Records old and new row data (as JSONB) for every change to a table.
- Validation trigger: A BEFORE trigger that checks business rules and either modifies data or raises an exception.
- Denormalization trigger: An AFTER trigger that maintains derived/cached data in another table.
- Prevention trigger: A BEFORE trigger that returns NULL or raises an exception to block certain operations.
Real World Context
A healthcare application must log every change to patient records for compliance. A generic audit trigger attached to all sensitive tables captures the before/after state as JSONB, creating an immutable change history that auditors can query. Meanwhile, a prevention trigger on the patients table blocks deletion of records that are under active treatment.
Deep Dive
Audit Trail
sqlCREATE TABLE audit_log ( id SERIAL PRIMARY KEY, table_name TEXT NOT NULL, operation TEXT NOT NULL, old_data JSONB, new_data JSONB, changed_by TEXT DEFAULT current_user, changed_at TIMESTAMPTZ DEFAULT NOW() ); CREATE OR REPLACE FUNCTION audit_trigger_func() RETURNS TRIGGER AS $$ BEGIN IF TG_OP = 'DELETE' THEN INSERT INTO audit_log (table_name, operation, old_data) VALUES (TG_TABLE_NAME, TG_OP, to_jsonb(OLD)); RETURN OLD; ELSIF TG_OP = 'UPDATE' THEN INSERT INTO audit_log (table_name, operation, old_data, new_data) VALUES (TG_TABLE_NAME, TG_OP, to_jsonb(OLD), to_jsonb(NEW)); RETURN NEW; ELSIF TG_OP = 'INSERT' THEN INSERT INTO audit_log (table_name, operation, new_data) VALUES (TG_TABLE_NAME, TG_OP, to_jsonb(NEW)); RETURN NEW; END IF; END; $$ LANGUAGE plpgsql; CREATE TRIGGER users_audit AFTER INSERT OR UPDATE OR DELETE ON users FOR EACH ROW EXECUTE FUNCTION audit_trigger_func();
Data Validation
sqlCREATE OR REPLACE FUNCTION validate_order() RETURNS TRIGGER AS $$ BEGIN IF NEW.total <= 0 THEN RAISE EXCEPTION 'Order total must be positive'; END IF; IF TG_OP = 'INSERT' THEN NEW.created_at := NOW(); NEW.status := 'pending'; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER validate_order_trigger BEFORE INSERT OR UPDATE ON orders FOR EACH ROW EXECUTE FUNCTION validate_order();
Maintaining Denormalized Counts
sqlCREATE OR REPLACE FUNCTION update_order_count() RETURNS TRIGGER AS $$ BEGIN IF TG_OP = 'INSERT' THEN UPDATE users SET order_count = order_count + 1 WHERE id = NEW.user_id; ELSIF TG_OP = 'DELETE' THEN UPDATE users SET order_count = order_count - 1 WHERE id = OLD.user_id; ELSIF TG_OP = 'UPDATE' AND OLD.user_id != NEW.user_id THEN UPDATE users SET order_count = order_count - 1 WHERE id = OLD.user_id; UPDATE users SET order_count = order_count + 1 WHERE id = NEW.user_id; END IF; RETURN NULL; -- AFTER trigger, return value ignored END; $$ LANGUAGE plpgsql;
Preventing Changes
sqlCREATE OR REPLACE FUNCTION prevent_delete_admin() RETURNS TRIGGER AS $$ BEGIN IF OLD.role = 'admin' THEN RAISE EXCEPTION 'Cannot delete admin users'; END IF; RETURN OLD; END; $$ LANGUAGE plpgsql;
Conditional Triggers (WHEN clause)
sqlCREATE TRIGGER log_price_change AFTER UPDATE OF price ON products FOR EACH ROW WHEN (OLD.price IS DISTINCT FROM NEW.price AND ABS(NEW.price - OLD.price) > 10) EXECUTE FUNCTION log_significant_change();
PostgreSQL 18: OLD/NEW in RETURNING
PostgreSQL 18 introduces the ability to reference OLD and NEW in RETURNING clauses of DML statements, making it easier to see both the before and after state in a single query:
sql-- See both old and new price in one UPDATE statement UPDATE products SET price = price * 1.1 WHERE category = 'electronics' RETURNING id, OLD.price AS old_price, NEW.price AS new_price;
This reduces the need for triggers that simply capture before/after values.
Common Pitfalls
- Trigger recursion -- A trigger on table A that modifies table A can fire itself recursively. Use
pg_trigger_depth()to detect and break recursion. - Deadlocks from cross-table triggers -- An AFTER trigger on table A updating table B, combined with a trigger on B updating A, can deadlock under concurrent writes.
Best Practices
- Use generic trigger functions -- A single audit function can serve multiple tables, reducing maintenance.
- Use IS DISTINCT FROM for change detection -- It correctly handles NULL-to-value and value-to-NULL transitions that
!=misses.
Summary
- Audit triggers capture before/after data as JSONB for change history.
- Validation triggers enforce business rules; prevention triggers block dangerous operations.
- PostgreSQL 18 adds OLD/NEW in RETURNING clauses, reducing the need for some trigger patterns.
Code Examples
CREATE OR REPLACE FUNCTION audit_trigger_func()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
INSERT INTO audit_log (table_name, operation, old_data)
VALUES (TG_TABLE_NAME, TG_OP, to_jsonb(OLD));
RETURN OLD;
ELSIF TG_OP = 'UPDATE' THEN
INSERT INTO audit_log (table_name, operation, old_data, new_data)
VALUES (TG_TABLE_NAME, TG_OP, to_jsonb(OLD), to_jsonb(NEW));
RETURN NEW;
ELSIF TG_OP = 'INSERT' THEN
INSERT INTO audit_log (table_name, operation, new_data)
VALUES (TG_TABLE_NAME, TG_OP, to_jsonb(NEW));
RETURN NEW;
END IF;
END;
$$ LANGUAGE plpgsql;