Skip to content
EL
Contents

Functions, procedures and triggers

Triggers

ConceptCore4 min read15 min hands-on#sql

The idea in 30 seconds

A trigger runs a function automatically when rows are inserted, updated or deleted. It runs inside the same statement, so it can change the row on its way in, reject it, or record what happened, and if it fails the whole statement fails. That power is also the cost: every write pays for it, and nobody reading the application code can see it.

Helps to know first: SQL data modification, Constraints and data integrity

On this page

When, and how often

A trigger connects a table event to a function. Two choices define it:

Fires once per rowFires once per statement
BEFORE the changeCan rewrite the row (NEW) or skip itRuns before anything happens
AFTER the changeSees the final row; can record it elsewhereCan see every changed row at once through a transition table

INSTEAD OF triggers exist too, on views, to make non-updatable views accept writes.

Changing the row on its way in

The classic use: keep an updated_at column honest, whatever code does the update.

ALTER TABLE orders ADD COLUMN updated_at timestamptz;

CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  NEW.updated_at := now();
  RETURN NEW;
END $$;

CREATE TRIGGER orders_updated_at
BEFORE UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION set_updated_at();

A row-level BEFORE trigger receives the new row as NEW. Whatever it returns is what gets written; returning NULL silently skips the row. For inserts, a column DEFAULT now() does the same job with no trigger at all.

Rejecting a change

A trigger that raises an error cancels the whole statement, not just the row:

CREATE OR REPLACE FUNCTION reject_big_orders() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  IF NEW.quantity > 10 THEN
    RAISE EXCEPTION 'quantity % is over the limit of 10', NEW.quantity;
  END IF;
  RETURN NEW;
END $$;

CREATE TRIGGER order_items_limit
BEFORE INSERT ON order_items
FOR EACH ROW EXECUTE FUNCTION reject_big_orders();

INSERT INTO order_items VALUES (106, 1, 2, 4500.00), (106, 2, 50, 1200.00);
ERROR:  quantity 50 is over the limit of 10
CONTEXT:  PL/pgSQL function reject_big_orders() line 4 at RAISE

The first row was valid, but the statement failed as a unit: order 106 still has exactly the one item it had before. For a rule about a single row like this one, a CHECK constraint is simpler and faster; triggers earn their place when a rule needs logic a constraint cannot express.

Recording what happened

An audit trail is the other classic use: every change also writes a copy to an audit table.

CREATE TABLE audit (table_name text, op text, row_data jsonb, at timestamptz DEFAULT now());

-- once per row
CREATE OR REPLACE FUNCTION audit_row() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
  INSERT INTO audit (table_name, op, row_data) VALUES (TG_TABLE_NAME, TG_OP, to_jsonb(NEW));
  RETURN NULL;
END $$;

-- once per statement, reading every new row from a transition table
CREATE OR REPLACE FUNCTION audit_statement() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
  INSERT INTO audit (table_name, op, row_data)
  SELECT TG_TABLE_NAME, TG_OP, to_jsonb(n) FROM new_rows n;
  RETURN NULL;
END $$;

CREATE TRIGGER order_items_audit AFTER INSERT ON order_items
REFERENCING NEW TABLE AS new_rows
FOR EACH STATEMENT EXECUTE FUNCTION audit_statement();

What triggers cost

Predict200,000 inserts

Inserting 200,000 rows into a table takes about 400 ms. Roughly how long does it take with a row-level audit trigger that writes one audit row per inserted row?

Every bulk load, backfill and data fix pays that cost too, often without the person running it knowing the trigger exists.

Check yourself

  1. A BEFORE INSERT row trigger returns NULL. What happens to the row?

    Show answer

    It is skipped silently: the insert does not happen for that row, and no error is raised.

  2. A trigger rejects the second of two rows in one INSERT. Is the first row saved?

    Show answer

    No. The trigger’s error fails the whole statement, so neither row is inserted.

  3. Why was the statement-level audit trigger faster than the row-level one?

    Show answer

    It ran once for the whole statement and inserted all audit rows from the transition table in one query, instead of calling the function and running a separate insert for each of the 200,000 rows.

Finished reading?

Saved in this browser only.

Sources

Where each claim on this page comes from.

  1. 1.
    PostgreSQL documentation: 37.1 Overview of Trigger Behavior
    Primary· §37.1 Overview of Trigger Behavior

    Triggers fire before, after or instead of INSERT, UPDATE or DELETE, either once per row or once per statement.

  2. 2.
    PostgreSQL documentation: 41.10 Trigger Functions
    Primary· §41.10 Trigger Functions

    A row-level BEFORE trigger can return a modified NEW to change the row, or NULL to skip the operation for that row.

  3. 3.
    PostgreSQL documentation: CREATE TRIGGER
    Primary· Parameters: REFERENCING

    Transition relations let an AFTER trigger see all rows inserted, deleted or modified by the statement at once.

Go further

What to read next, and why it is worth the time.

  1. 1.
    PostgreSQL documentation: 37.1 Overview of Trigger Behavior
    Primary· §37.1

    Lays out exactly when each kind of trigger fires relative to the row change and to each other.

Applies to PostgreSQL 18. Draft: not yet fact-checked end to end.