PL/pgSQL CREATE TRIGGER
Master CREATE TRIGGER — the two-step model of defining a trigger function then binding it to a table, the EXECUTE FUNCTION clause, conditional firing with WHEN, multi-event triggers via OR, and the TG_OP dispatch pattern. Critical fixes: original tutorial had stray return_type in syntax, included LANGUAGE clause inside CREATE TRIGGER (it belongs in CREATE FUNCTION), and referenced NEW.NAME for non-existent column.
Creating a trigger in PostgreSQL is a two-step process:
- Define a trigger function — a regular PL/pgSQL function with no parameters and return type
trigger. - Bind that function to a table via the
CREATE TRIGGERstatement, specifying when (BEFORE/AFTER) and on what (INSERT/UPDATE/etc.) it should fire.
The two-step model is intentional. One trigger function can be reused across multiple triggers — the same audit-logging function, for example, might be wired up to several tables.
RETURNS TRIGGER return_type with a stray return_type word. The correct return is just TRIGGER — that's already a complete return type. Fixed above.Three things to know about the function:
- No parameters. The trigger system passes data via implicit variables (
NEW,OLD,TG_OP, etc.), not function arguments. - Returns type
TRIGGER. Specifically, the function shouldRETURNa row record (or NULL). - Different return semantics for different timings. See the table below.
| Trigger | Return value affects |
|---|---|
BEFORE row trigger | Returning NEW continues; returning a modified NEW writes those changes; returning NULL silently skips this row |
BEFORE DELETE row trigger | Returning OLD allows delete; returning NULL skips this row |
AFTER row triggers | Return value is ignored — the row is already written |
| Statement-level triggers | Return value is ignored — typically RETURN NULL |
EXECUTE PROCEDURE; modern versions (PG ≥11) prefer EXECUTE FUNCTION. Both still work — they're synonyms — but new code should use EXECUTE FUNCTION.| Clause | Purpose |
|---|---|
BEFORE / AFTER / INSTEAD OF | When the trigger fires relative to the operation |
INSERT / UPDATE / DELETE / TRUNCATE | What event fires the trigger. Combine with OR to fire on multiple events. |
ON table_name | The table the trigger watches |
FOR EACH ROW / FOR EACH STATEMENT | Whether the trigger fires per row or per statement. Defaults to STATEMENT. |
WHEN (condition) | Optional filter — trigger fires only when the condition is true |
The classic use case: log every insertion to a users table into an audit table. Walk through it step by step.
DROP TABLE IF EXISTS user_audit;
DROP TABLE IF EXISTS users;
CREATE TABLE users (
user_id SERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT current_timestamp
);
CREATE TABLE user_audit (
audit_id SERIAL PRIMARY KEY,
user_id INTEGER,
username VARCHAR(50),
action VARCHAR(10),
action_time TIMESTAMP DEFAULT current_timestamp
);
This function will run after every insertion, copying the new user data into the audit table:
CREATE OR REPLACE FUNCTION log_new_user()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
INSERT INTO user_audit (user_id, username, action)
VALUES (NEW.user_id, NEW.username, TG_OP);
RETURN NEW;
END;
$$;
Three things worth noting:
NEW.user_idandNEW.usernamereference the row that was just inserted intousers.TG_OPis the operation name as a string ('INSERT') — using it instead of a hardcoded literal lets the same function handle multiple operations.RETURN NEW— for an AFTER trigger this return value is ignored, but it's still required syntactically. Convention is to return NEW.
CREATE TRIGGER user_insert_trigger
AFTER INSERT
ON users
FOR EACH ROW
EXECUTE FUNCTION log_new_user();
LANGUAGE PLPGSQL inside the CREATE TRIGGER statement. That's wrong — LANGUAGE belongs in CREATE FUNCTION, not in CREATE TRIGGER. Triggers don't have a language clause; they execute the function you point them at, which already declares its own language. Fixed above.INSERT INTO users (username) VALUES ('alice');
INSERT INTO users (username) VALUES ('bob');
SELECT * FROM user_audit;
The trigger fired automatically for each insert — no application code involved. The audit table now has its log without anyone explicitly writing to it.
The optional WHEN clause lets the trigger fire only when a condition holds — useful when you want a single function applied selectively:
CREATE TRIGGER audit_salary_changes
AFTER UPDATE ON employees
FOR EACH ROW
WHEN (NEW.salary IS DISTINCT FROM OLD.salary)
EXECUTE FUNCTION log_salary_change();
The trigger fires only when the salary actually changed — not when other columns were updated. The IS DISTINCT FROM operator handles NULL comparisons correctly (unlike !=, which returns NULL if either side is NULL).
One trigger can handle several event types via OR:
CREATE TRIGGER user_change_audit
AFTER INSERT OR UPDATE OR DELETE ON users
FOR EACH ROW
EXECUTE FUNCTION log_user_change();
Inside the trigger function, branch on TG_OP to decide what to do for each event type:
CREATE OR REPLACE FUNCTION log_user_change()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO user_audit (user_id, username, action)
VALUES (NEW.user_id, NEW.username, 'INSERT');
RETURN NEW;
ELSIF TG_OP = 'UPDATE' THEN
INSERT INTO user_audit (user_id, username, action)
VALUES (NEW.user_id, NEW.username, 'UPDATE');
RETURN NEW;
ELSIF TG_OP = 'DELETE' THEN
INSERT INTO user_audit (user_id, username, action)
VALUES (OLD.user_id, OLD.username, 'DELETE');
RETURN OLD;
END IF;
RETURN NULL; -- defensive — should never reach here
END;
$$;
Triggers are removed with DROP TRIGGER:
DROP TRIGGER IF EXISTS user_insert_trigger ON users;
ON table_name clause is required because two different tables could each have a trigger named after_insert. The SQL standard treats trigger names as global; PostgreSQL doesn't.- Name triggers descriptively. A trigger called
trg1tells you nothing;users_after_insert_audittells you when it fires and what it does. - Use
EXECUTE FUNCTIONin new code, notEXECUTE PROCEDURE. Both work, but the modern name reflects that triggers really do call functions, not procedures. - Don't put
LANGUAGEin CREATE TRIGGER. The language clause belongs in CREATE FUNCTION; the trigger just wires the function to a table. - Use
WHENconditions to filter cheaply. Filtering inside the function still incurs the function-call overhead;WHENis checked first, before the function runs. - Branch on
TG_OPwhen one function handles multiple operations. Saves duplication; keeps logic in one place. - Always include
DROP TRIGGER IF EXISTSin deployment scripts.CREATE OR REPLACE TRIGGERdoesn't exist in PostgreSQL the way it does for functions.
- Creating a trigger is a two-step process: define a trigger function (returning
trigger) then bind it to a table withCREATE TRIGGER. - The trigger function takes no parameters; it accesses row data via implicit
NEWandOLDrecords, plusTG_OP,TG_NAME, and otherTG_*variables. CREATE TRIGGERspecifies the timing (BEFORE/AFTER/INSTEAD OF), event(s), table, scope (ROW/STATEMENT), and an optional WHEN condition.- Use
EXECUTE FUNCTION(modern) overEXECUTE PROCEDURE(legacy synonym). LANGUAGEbelongs in CREATE FUNCTION, not CREATE TRIGGER.- One trigger function can be reused across multiple triggers — branch on
TG_OPwhen handling multiple events. - Drop a trigger with
DROP TRIGGER trigger_name ON table_name— trigger names are scoped to their table.