We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
Practical Triggers
This lesson walks through four real-world trigger patterns that you will encounter in production PostgreSQL systems. Each example is complete and ready to use with minor adaptation.
Pattern 1: Audit Logging
Audit logging is the most common use of triggers. Every change to a table is recorded in a separate log table with who changed what and when.
Step 1: Create the Audit Log Table
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
table_name TEXT NOT NULL,
operation TEXT NOT NULL, -- INSERT, UPDATE, DELETE
old_data JSONB, -- previous row (UPDATE/DELETE)
new_data JSONB, -- new row (INSERT/UPDATE)
changed_by TEXT DEFAULT CURRENT_USER,
changed_at TIMESTAMPTZ DEFAULT NOW()
);
Step 2: Create a Reusable Trigger Function
This single function works on any table:
CREATE FUNCTION fn_audit_log()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO audit_log(table_name, operation, new_data)
VALUES (TG_TABLE_NAME, TG_OP, ROW_TO_JSON(NEW)::JSONB);
RETURN NEW;
ELSIF TG_OP = 'UPDATE' THEN
INSERT INTO audit_log(table_name, operation, old_data, new_data)
VALUES (TG_TABLE_NAME, TG_OP,
ROW_TO_JSON(OLD)::JSONB,
ROW_TO_JSON(NEW)::JSONB);
RETURN NEW;
ELSIF TG_OP = 'DELETE' THEN
INSERT INTO audit_log(table_name, operation, old_data)
VALUES (TG_TABLE_NAME, TG_OP, ROW_TO_JSON(OLD)::JSONB);
RETURN OLD;
END IF;
RETURN NULL;
END;
$$;
Step 3: Attach It to Tables
CREATE TRIGGER trg_audit_employees
AFTER INSERT OR UPDATE OR DELETE ON employees
FOR EACH ROW
EXECUTE FUNCTION fn_audit_log();
CREATE TRIGGER trg_audit_orders
AFTER INSERT OR UPDATE OR DELETE ON orders
FOR EACH ROW
EXECUTE FUNCTION fn_audit_log();
Querying the Audit Log
-- See all changes to the employees table
SELECT operation, old_data, new_data, changed_by, changed_at
FROM audit_log
WHERE table_name = 'employees'
ORDER BY changed_at DESC;
-- Find all salary changes
SELECT
old_data->>'name' AS employee,
old_data->>'salary' AS old_salary,
new_data->>'salary' AS new_salary,
changed_at
FROM audit_log
WHERE table_name = 'employees'
AND operation = 'UPDATE'
AND old_data->>'salary' != new_data->>'salary';
Pattern 2: Auto-Update Timestamps
Most applications have created_at and updated_at columns. A trigger can manage updated_at automatically so application code never needs to set it.
The Trigger Function
CREATE FUNCTION fn_set_updated_at()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at := NOW();
RETURN NEW;
END;
$$;
Attach to Any Table with an updated_at Column
-- Assume this table structure:
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
price NUMERIC(10, 2),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TRIGGER trg_products_updated_at
BEFORE UPDATE ON products
FOR EACH ROW
EXECUTE FUNCTION fn_set_updated_at();
Now test it:
INSERT INTO products(name, price)
VALUES ('Widget', 9.99);
-- Wait a moment, then update
UPDATE products SET price = 12.99 WHERE name = 'Widget';
SELECT name, price, created_at, updated_at FROM products;
The created_at stays the same (set on INSERT), while updated_at is automatically refreshed on every UPDATE.
Optimization: Skip When Nothing Changed
You can avoid writing a new updated_at when the row data did not actually change:
CREATE FUNCTION fn_set_updated_at_if_changed()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
-- Skip if nothing changed (comparing entire rows minus updated_at)
IF NEW IS NOT DISTINCT FROM OLD THEN
RETURN NEW; -- no change, keep old updated_at
END IF;
NEW.updated_at := NOW();
RETURN NEW;
END;
$$;
Pattern 3: Validation Triggers
While CHECK constraints handle simple validations, triggers can enforce complex business rules that span multiple columns or tables.
Example: Business Hours Validation
CREATE FUNCTION fn_validate_order()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
v_product_stock INTEGER;
BEGIN
-- Rule 1: Quantity must be positive
IF NEW.quantity <= 0 THEN
RAISE EXCEPTION 'Quantity must be positive, got: %', NEW.quantity;
END IF;
-- Rule 2: Total price must match quantity * unit price (with tolerance)
-- (cross-table validation)
DECLARE
v_unit_price NUMERIC;
BEGIN
SELECT price INTO v_unit_price
FROM products
WHERE id = NEW.product_id;
IF v_unit_price IS NOT NULL THEN
IF ABS(NEW.total_price - (NEW.quantity * v_unit_price)) > 0.01 THEN
RAISE EXCEPTION
'Total price % does not match quantity (%) x unit price (%)',
NEW.total_price, NEW.quantity, v_unit_price;
END IF;
END IF;
END;
-- Rule 3: Cannot place orders for out-of-stock items
SELECT stock INTO v_product_stock
FROM inventory
WHERE product_id = NEW.product_id;
IF v_product_stock IS NOT NULL AND v_product_stock < NEW.quantity THEN
RAISE EXCEPTION
'Insufficient stock: requested %, available %',
NEW.quantity, v_product_stock;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_validate_order
BEFORE INSERT OR UPDATE ON orders
FOR EACH ROW
EXECUTE FUNCTION fn_validate_order();
Example: Email Format Validation
CREATE FUNCTION fn_validate_email()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
-- Normalize to lowercase
NEW.email := LOWER(TRIM(NEW.email));
-- Basic format check
IF NEW.email !~ '^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$' THEN
RAISE EXCEPTION 'Invalid email format: %', NEW.email;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_validate_email
BEFORE INSERT OR UPDATE OF email ON employees
FOR EACH ROW
EXECUTE FUNCTION fn_validate_email();
This trigger does double duty: it normalizes the email (lowercase, trimmed) and validates the format.
Example: Preventing Dangerous Deletes
CREATE FUNCTION fn_prevent_active_delete()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF OLD.is_active = TRUE THEN
RAISE EXCEPTION
'Cannot delete active employee "%". Deactivate them first.',
OLD.name;
END IF;
RETURN OLD; -- allow the delete for inactive employees
END;
$$;
CREATE TRIGGER trg_prevent_active_delete
BEFORE DELETE ON employees
FOR EACH ROW
EXECUTE FUNCTION fn_prevent_active_delete();
Pattern 4: Cascade Update Triggers
Foreign key constraints handle cascade deletes, but sometimes you need custom cascade logic for updates -- for example, keeping denormalized data in sync.
Example: Sync Denormalized Customer Name
Suppose orders stores a copy of the customer name for fast reads. When the customer name changes, we want all their orders updated:
CREATE FUNCTION fn_cascade_customer_name()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
-- Only act if the name actually changed
IF OLD.name IS DISTINCT FROM NEW.name THEN
UPDATE orders
SET customer_name = NEW.name
WHERE customer_name = OLD.name;
RAISE NOTICE 'Updated % order(s) with new customer name: %',
(SELECT COUNT(*) FROM orders WHERE customer_name = NEW.name),
NEW.name;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_cascade_customer_name
AFTER UPDATE OF name ON customers
FOR EACH ROW
WHEN (OLD.name IS DISTINCT FROM NEW.name)
EXECUTE FUNCTION fn_cascade_customer_name();
Example: Maintaining a Summary Table
Keep a department_stats table in sync with changes to employees:
CREATE TABLE department_stats (
department TEXT PRIMARY KEY,
employee_count INTEGER DEFAULT 0,
total_salary NUMERIC DEFAULT 0,
avg_salary NUMERIC DEFAULT 0
);
CREATE FUNCTION fn_update_dept_stats()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
-- Recalculate stats for affected department(s)
IF TG_OP = 'DELETE' OR TG_OP = 'UPDATE' THEN
INSERT INTO department_stats(department, employee_count, total_salary, avg_salary)
SELECT
department,
COUNT(*),
COALESCE(SUM(salary), 0),
COALESCE(AVG(salary), 0)
FROM employees
WHERE department = OLD.department
GROUP BY department
ON CONFLICT (department)
DO UPDATE SET
employee_count = EXCLUDED.employee_count,
total_salary = EXCLUDED.total_salary,
avg_salary = EXCLUDED.avg_salary;
END IF;
IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
INSERT INTO department_stats(department, employee_count, total_salary, avg_salary)
SELECT
department,
COUNT(*),
COALESCE(SUM(salary), 0),
COALESCE(AVG(salary), 0)
FROM employees
WHERE department = NEW.department
GROUP BY department
ON CONFLICT (department)
DO UPDATE SET
employee_count = EXCLUDED.employee_count,
total_salary = EXCLUDED.total_salary,
avg_salary = EXCLUDED.avg_salary;
END IF;
RETURN NULL; -- AFTER trigger, return value is ignored
END;
$$;
CREATE TRIGGER trg_update_dept_stats
AFTER INSERT OR UPDATE OR DELETE ON employees
FOR EACH ROW
EXECUTE FUNCTION fn_update_dept_stats();
Best Practices
Do
- Keep trigger functions short and fast. They run inside every affected transaction.
- Use AFTER triggers for logging. This ensures you only log changes that survived constraint checks.
- Use BEFORE triggers for data modification. Setting defaults, normalizing values, and validation belong here.
- Use the WHEN clause to avoid entering the function unnecessarily.
- Use
IS DISTINCT FROMinstead of!=to correctly handle NULL comparisons. - Make trigger functions reusable by using
TG_TABLE_NAME,TG_OP, andROW_TO_JSON().
Avoid
- Complex business logic in triggers. If the logic is long, call a regular function from the trigger function.
- Triggers that modify the same table (can cause infinite loops or confusing behavior).
- Triggers that silently swallow errors. Catch exceptions only when you have a clear recovery strategy.
- Too many triggers on one table. Each trigger adds overhead to every write operation.
- Relying on trigger execution order. If order matters, reconsider the design.
Debugging Triggers
Use RAISE NOTICE to trace execution:
CREATE FUNCTION fn_debug_trigger()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
RAISE NOTICE 'Trigger: %, Table: %, Op: %, Level: %',
TG_NAME, TG_TABLE_NAME, TG_OP, TG_LEVEL;
IF TG_OP = 'UPDATE' THEN
RAISE NOTICE 'OLD: %', ROW_TO_JSON(OLD);
RAISE NOTICE 'NEW: %', ROW_TO_JSON(NEW);
END IF;
IF TG_OP IN ('INSERT', 'UPDATE') THEN
RETURN NEW;
ELSE
RETURN OLD;
END IF;
END;
$$;
Try It Yourself
Design trigger solutions for these scenarios (implement them on your own PostgreSQL instance):
- Create an audit trigger for the
salestable that records who made each change and what the old and new values were. - Write a trigger on
ordersthat automatically setsstatus = 'pending'on INSERT if no status is provided. - Write a trigger that prevents updating an order's status from
'shipped'back to'pending'. - Design a trigger that maintains an
order_countcolumn on acustomerstable whenever orders are inserted or deleted.
Summary
| Pattern | Timing | Level | Key Technique |
|---|---|---|---|
| Audit logging | AFTER | ROW | ROW_TO_JSON(), TG_OP, JSONB storage |
| Auto timestamps | BEFORE | ROW | NEW.updated_at := NOW() |
| Validation | BEFORE | ROW | RAISE EXCEPTION to reject invalid data |
| Cascade updates | AFTER | ROW | IS DISTINCT FROM guard, update related tables |