PostgreSQL UPDATE PostgreSQL · Triggers · UPDATE

PL/pgSQL BEFORE/AFTER UPDATE Triggers

Master UPDATE triggers — BEFORE for validation/normalization (can modify NEW), AFTER for audit logging (cannot modify NEW). Critical fixes: original INSERT was missing VALUES keyword (syntax error), CREATE TRIGGER had two ON clauses (syntax error), and AFTER UPDATE trigger tried to modify NEW.last_updated which has no effect — that pattern requires BEFORE UPDATE. Uses IS DISTINCT FROM for NULL-safe column comparisons.

UPDATE triggers fire when an UPDATE statement modifies rows in a table. The two timing variants serve very different purposes:

BEFORE UPDATEAFTER UPDATE
Fires before the row is written to diskFires after the row is written
Can modify NEW — those changes will be savedCannot modify NEW — too late, the row is already on disk
Can return NULL to skip the row entirelyReturn value is ignored
Sees both OLD and NEWSees both OLD and NEW
Use for: validation, normalization, derived columns, auto-updated timestampsUse for: audit logging, cascading actions, notifications
⚠️ Common bug: trying to set a "last_updated" timestamp in an AFTER trigger. If you assign NEW.last_updated := CURRENT_TIMESTAMP; inside an AFTER UPDATE trigger, the assignment has no effect — the row is already written. To auto-update a timestamp on every UPDATE, you must use a BEFORE trigger.
PL/pgSQL — Setup
DROP TABLE IF EXISTS employees CASCADE;

CREATE TABLE employees (
    id           SERIAL PRIMARY KEY,
    name         VARCHAR(100),
    salary       NUMERIC,
    last_updated TIMESTAMP
);

INSERT INTO employees (name, salary) VALUES
    ('Alice',   50000),
    ('Bob',     60000),
    ('Charlie', 55000);
⚠️ The original tutorial's INSERT was missing the VALUES keyword — it wrote INSERT INTO employees (name, salary) directly followed by ('Alice', 50000), .... That's a syntax error. Fixed above.

BEFORE UPDATE triggers are the right place for input checks. The salary-can't-be-negative rule is a classic example:

Example 1 — Reject Negative Salaries
PL/pgSQL — validation function
CREATE OR REPLACE FUNCTION check_salary()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
    IF NEW.salary < 0 THEN
        RAISE EXCEPTION 'Salary cannot be negative (got %)', NEW.salary;
    END IF;

    RETURN NEW;
END;
$$;

CREATE TRIGGER before_update_salary
    BEFORE UPDATE ON employees
    FOR EACH ROW
    EXECUTE FUNCTION check_salary();
⚠️ The original tutorial's CREATE TRIGGER had two ON clauses — first BEFORE UPDATE ON employees, then ON inventory. That's a hard syntax error. Fixed above with a single, correct ON employees.
Example 2 — Test the Validation
PL/pgSQL — try negative, then positive
-- Should fail: trigger raises an exception
UPDATE employees SET salary = -1000 WHERE id = 1;

-- Should succeed: passes the check
UPDATE employees SET salary = 70000 WHERE id = 1;
SELECT * FROM employees WHERE id = 1;
OUTPUT
psql — validation
postgres=#UPDATE employees SET salary = -1000 WHERE id = 1;
ERROR: Salary cannot be negative (got -1000)
CONTEXT: PL/pgSQL function check_salary() line 4 at RAISE
postgres=#UPDATE employees SET salary = 70000 WHERE id = 1;
UPDATE 1
postgres=#SELECT * FROM employees WHERE id = 1;
id | name | salary | last_updated
----+-------+--------+--------------
1 | Alice | 70000 |
(1 row)

Negative rejected, positive accepted. Note that last_updated is still NULL — neither this trigger nor anything else has set it. Let's fix that next.

💡 Could a CHECK constraint do this? Yes — CHECK (salary >= 0) on the column would reject negative values declaratively, with a clearer error and visible in the table definition. Reach for triggers when the rule depends on multiple columns, references other tables, or is too dynamic for a CHECK constraint.

This is the canonical reason to use a BEFORE trigger to modify NEW: keep a last_updated column current automatically. The application doesn't have to remember; the database guarantees it.

Example 3 — Auto-Update last_updated (BEFORE)
PL/pgSQL — set last_updated
CREATE OR REPLACE FUNCTION set_last_updated()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
    NEW.last_updated := CURRENT_TIMESTAMP;
    RETURN NEW;
END;
$$;

CREATE TRIGGER employees_set_last_updated
    BEFORE UPDATE ON employees
    FOR EACH ROW
    EXECUTE FUNCTION set_last_updated();

UPDATE employees SET salary = 75000 WHERE id = 1;
SELECT id, name, salary, last_updated FROM employees WHERE id = 1;
OUTPUT
psql — auto-timestamp
postgres=#UPDATE employees SET salary = 75000 WHERE id = 1;
UPDATE 1
postgres=#SELECT id, name, salary, last_updated FROM employees WHERE id = 1;
id | name | salary | last_updated
----+-------+--------+----------------------------
1 | Alice | 75000 | 2026-05-03 14:08:55.122+00
(1 row)
⚠️ The original tutorial put this same logic in an AFTER UPDATE trigger. That doesn't work — assignments to NEW in an AFTER trigger are silently ignored because the row is already written. The original example would have shown the column staying NULL forever, which contradicts the page's own claim that the timestamp would be updated. Must be BEFORE.

The right use for AFTER triggers: react to changes that have already happened. Audit logging is the canonical case.

Example 4 — Log Salary Changes
PL/pgSQL — audit table and trigger
CREATE TABLE IF NOT EXISTS salary_audit (
    audit_id   SERIAL PRIMARY KEY,
    employee_id INT,
    old_salary NUMERIC,
    new_salary NUMERIC,
    changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE OR REPLACE FUNCTION log_salary_change()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
    -- Only log when salary actually changed
    IF NEW.salary IS DISTINCT FROM OLD.salary THEN
        INSERT INTO salary_audit (employee_id, old_salary, new_salary)
        VALUES (NEW.id, OLD.salary, NEW.salary);
    END IF;

    RETURN NEW;   -- ignored for AFTER triggers, but RETURN NEW is convention
END;
$$;

CREATE TRIGGER after_update_salary
    AFTER UPDATE ON employees
    FOR EACH ROW
    WHEN (NEW.salary IS DISTINCT FROM OLD.salary)
    EXECUTE FUNCTION log_salary_change();

Two safety nets here:

  • WHEN (NEW.salary IS DISTINCT FROM OLD.salary) — the trigger doesn't even fire when salary is unchanged. Cheaper than running the function and bailing out inside.
  • The same check inside the function as defensive backup, in case the trigger gets re-created without the WHEN clause.
Example 5 — Verify the Audit Log
PL/pgSQL — make changes and check audit
UPDATE employees SET salary = 80000 WHERE id = 1;
UPDATE employees SET salary = 80000 WHERE id = 1;  -- no change → no audit row
UPDATE employees SET salary = 65000 WHERE id = 2;

SELECT * FROM salary_audit ORDER BY audit_id;
OUTPUT
psql — audit verified
postgres=#SELECT * FROM salary_audit ORDER BY audit_id;
audit_id | employee_id | old_salary | new_salary | changed_at
----------+-------------+------------+------------+----------------------------
1 | 1 | 75000 | 80000 | 2026-05-03 14:09:11.402+00
2 | 2 | 60000 | 65000 | 2026-05-03 14:09:11.474+00
(2 rows)

Two audit entries — one for each genuine change. The unchanged-salary update didn't make it past the WHEN clause, so it didn't fire the trigger.

GoalUse
Validate inputBEFORE — RAISE EXCEPTION rolls back the transaction
Modify the row before it's saved (computed columns, normalization, last_updated)BEFORE — assign to NEW
Skip the row entirelyBEFORE — RETURN NULL
Audit log of what happenedAFTER — operates on the row that was actually written
Cascade to other tables (insert/update/delete dependents)AFTER — the row is already there; safe to reference its FK
Send notifications (NOTIFY, queue jobs)AFTER — only fire if the change actually committed
  1. Modifying NEW only works in BEFORE triggers. AFTER triggers can read NEW but can't change what was written.
  2. For audit logging, use IS DISTINCT FROM — handles NULL correctly. NEW.col != OLD.col returns NULL when either side is NULL, treating it as not-different and silently failing to audit.
  3. Filter with WHEN before the function fires — saves the function-call overhead when the change isn't relevant.
  4. Validate with CHECK constraints when possible. CHECK (salary >= 0) is faster, simpler, and visible in the schema. Use BEFORE triggers for rules CHECK can't express.
  5. Keep BEFORE trigger functions fast. They run on the hot path of every UPDATE; complex logic slows every write.
  6. Return NEW from BEFORE UPDATE triggers by convention. Return NULL deliberately when you mean to skip the row.
  • BEFORE UPDATE triggers fire before the write. They can validate, modify NEW, or return NULL to skip. They run on the hot write path.
  • AFTER UPDATE triggers fire after the write. They can react to the change but cannot modify NEW. They're the right place for audit logging and cascading actions.
  • Common bug: trying to set NEW.last_updated from an AFTER trigger — has no effect. Must be BEFORE.
  • Use IS DISTINCT FROM instead of != for NULL-safe column comparisons.
  • Use WHEN clauses to filter before the function runs, saving overhead when the change isn't interesting.
  • For simple constraints like "salary >= 0," prefer a CHECK constraint over a trigger — faster, declarative, visible in the schema.