Stored Procedures & Triggers Cheat Sheet
Covers writing stored procedures, functions, and triggers in SQL for encapsulating business logic and automating actions on data changes.
PL/pgSQL Function
A reusable function that returns a scalar value.
CREATE OR REPLACE FUNCTION get_customer_total(p_customer_id INT)RETURNS NUMERIC AS $$DECLARE v_total NUMERIC;BEGIN SELECT COALESCE(SUM(total), 0) INTO v_total FROM orders WHERE customer_id = p_customer_id; RETURN v_total;END;$$ LANGUAGE plpgsql;-- Call itSELECT get_customer_total(42);
MySQL Stored Procedure
A procedure that performs a transactional update.
DELIMITER $$CREATE PROCEDURE ApplyDiscount(IN p_order_id INT, IN p_pct DECIMAL(5,2))BEGIN DECLARE v_total DECIMAL(10,2); START TRANSACTION; UPDATE orders SET total = total - (total * p_pct / 100) WHERE id = p_order_id; COMMIT;END $$DELIMITER ;CALL ApplyDiscount(101, 10.00);
Trigger for Audit Logging
Automatically log status changes on update.
CREATE TABLE order_audit ( id SERIAL PRIMARY KEY, order_id INT, old_status TEXT, new_status TEXT, changed_at TIMESTAMP DEFAULT now());CREATE OR REPLACE FUNCTION log_status_change() RETURNS TRIGGER AS $$BEGIN IF OLD.status IS DISTINCT FROM NEW.status THEN INSERT INTO order_audit(order_id, old_status, new_status) VALUES (OLD.id, OLD.status, NEW.status); END IF; RETURN NEW;END;$$ LANGUAGE plpgsql;CREATE TRIGGER trg_order_status_changeAFTER UPDATE ON ordersFOR EACH ROWEXECUTE FUNCTION log_status_change();
Key Concepts
Terminology for server-side database logic.
- Stored procedure- A named, precompiled block of SQL/procedural code stored in the database and invoked with CALL
- Function- Similar to a procedure but returns a value and can be used inside a SELECT statement
- Trigger- Code that automatically runs BEFORE or AFTER an INSERT/UPDATE/DELETE on a table
- ROW vs STATEMENT trigger- FOR EACH ROW fires once per affected row; a statement-level trigger fires once per statement regardless of rows affected
- OLD / NEW- Pseudo-records available inside a trigger representing the row before (OLD) and after (NEW) the change
- Recursive trigger risk- A trigger that modifies the same table it's attached to can cause infinite recursion if not guarded
Exception Handling in PL/pgSQL
Catch and re-raise specific error conditions inside a function block.
CREATE OR REPLACE FUNCTION transfer_funds(p_from INT, p_to INT, p_amount NUMERIC)RETURNS VOID AS $$BEGIN UPDATE accounts SET balance = balance - p_amount WHERE id = p_from; IF NOT FOUND THEN RAISE EXCEPTION 'source account % not found', p_from USING ERRCODE = 'no_data_found'; END IF; IF (SELECT balance FROM accounts WHERE id = p_from) < 0 THEN RAISE EXCEPTION 'insufficient funds in account %', p_from USING ERRCODE = 'check_violation'; END IF; UPDATE accounts SET balance = balance + p_amount WHERE id = p_to;EXCEPTION WHEN check_violation THEN RAISE NOTICE 'transfer rolled back: %', SQLERRM; RAISE; WHEN OTHERS THEN RAISE WARNING 'unexpected error in transfer_funds: % (SQLSTATE %)', SQLERRM, SQLSTATE; RAISE;END;$$ LANGUAGE plpgsql;
SQL Server INSTEAD OF Trigger on a View
Make an otherwise non-updatable multi-table view writable.
CREATE VIEW dbo.vw_order_summary ASSELECT o.id, o.customer_id, c.name AS customer_name, o.totalFROM orders oJOIN customers c ON c.id = o.customer_id;GOCREATE TRIGGER trg_order_summary_updateON dbo.vw_order_summaryINSTEAD OF UPDATEASBEGIN SET NOCOUNT ON; UPDATE o SET o.total = i.total FROM orders o JOIN inserted i ON i.id = o.id; -- inserted/deleted pseudo-tables expose the batch of rows being changed IF UPDATE(customer_name) RAISERROR('customer_name is derived and cannot be updated directly', 16, 1);END;
Recursive CTE Wrapped in a Procedure
Walk a hierarchical table (org chart) and return it as a result set.
DELIMITER $$CREATE PROCEDURE GetOrgChart(IN p_root_id INT)BEGIN WITH RECURSIVE org AS ( SELECT id, name, manager_id, 1 AS depth FROM employees WHERE id = p_root_id UNION ALL SELECT e.id, e.name, e.manager_id, org.depth + 1 FROM employees e JOIN org ON e.manager_id = org.id ) SELECT * FROM org ORDER BY depth, name;END $$DELIMITER ;CALL GetOrgChart(1);
Guarding Against Trigger Recursion
Use pg_trigger_depth() to stop a self-referential trigger from looping.
CREATE OR REPLACE FUNCTION propagate_parent_total() RETURNS TRIGGER AS $$BEGIN -- Bail out if this trigger is already nested inside itself IF pg_trigger_depth() > 1 THEN RETURN NEW; END IF; UPDATE categories SET running_total = running_total + (NEW.amount - COALESCE(OLD.amount, 0)) WHERE id = NEW.category_id; RETURN NEW;END;$$ LANGUAGE plpgsql;CREATE TRIGGER trg_propagate_totalAFTER INSERT OR UPDATE ON transactionsFOR EACH ROWEXECUTE FUNCTION propagate_parent_total();
Advanced Concepts
Terminology that matters once you move past simple CRUD triggers.
- Transition tables (REFERENCING clause)- Postgres AFTER triggers can expose the whole affected batch as OLD TABLE / NEW TABLE for set-based logic instead of row-by-row processing
- SECURITY DEFINER vs SECURITY INVOKER- A function marked SECURITY DEFINER runs with the privileges of its owner, not the caller — powerful for controlled privilege escalation but a privilege-escalation risk if the search_path isn't pinned
- Deferred constraint triggers- CREATE CONSTRAINT TRIGGER ... DEFERRABLE INITIALLY DEFERRED runs at COMMIT time instead of per-statement, useful for cross-row invariants checked only once per transaction
- WHEN clause on triggers- CREATE TRIGGER ... WHEN (condition) filters which rows fire the trigger function at the engine level, avoiding a wasted function call for no-op updates
- Statement-level AFTER triggers for batch aggregation- Firing once per statement with transition tables lets you update a summary table in one UPDATE instead of once per row, avoiding N separate lock acquisitions
- search_path pinning- SECURITY DEFINER functions should SET search_path = pg_catalog, public explicitly to prevent a malicious schema earlier in the caller's search_path from hijacking unqualified object references
- Trigger execution order- Multiple triggers of the same type/timing on one table fire in alphabetical order of trigger name, not creation order — name them deliberately (e.g. 'aa_validate', 'zz_audit')
Keep triggers side-effect-light and fast — logic buried in triggers is invisible to anyone reading application code and can silently slow down bulk inserts/updates; prefer explicit application-layer logic or a documented CDC pipeline for anything beyond simple auditing or constraint enforcement.