PostgreSQL Trigger Protocol Violated (SQLSTATE 39P01)

When a trigger function violates PostgreSQL's trigger calling convention, the server raises:

ERROR:  trigger_protocol_violated
SQLSTATE: 39P01

In practice the message often includes additional context, such as:

ERROR:  trigger function must return NULL or relation row type

or

ERROR:  null result from a trigger body that fires before an UPDATE/INSERT event must be an explicit RETURN NULL, not just falling off the end of the function

SQLSTATE 39P01 belongs to class 39External Routine Invocation Exception — a class shared with PL/SQL-style invocation errors. The condition name is trigger_protocol_violated.

What This Error Means

PostgreSQL triggers are driven by a strict calling convention. A trigger function is a special kind of function that must be declared with a return type of trigger and must follow explicit rules about what it returns depending on the trigger type (row-level vs. statement-level, BEFORE vs. AFTER vs. INSTEAD OF).

For row-level BEFORE triggers, the function must return either:

  • The NEW row (to allow the operation to proceed with that row),
  • A modified version of NEW (to alter the row being inserted or updated),
  • NULL (to silently cancel the operation for that row), or
  • The OLD row (for DELETE triggers, to allow deletion to proceed).

For row-level AFTER triggers and statement-level triggers, the return value is ignored — but PostgreSQL still requires the function to return NULL explicitly; returning a non-NULL value from an AFTER trigger is not an error in itself but returning an incompatible type is.

PostgreSQL raises 39P01 when the value returned by the trigger function is incompatible with these rules — for example, returning a plain integer, a text string, or a composite type that does not match the table's row type. The error is raised at execution time, after the trigger function has returned but before PostgreSQL applies the result.

The transaction is aborted when this error occurs. Any ongoing statement that fired the trigger is rolled back. If you are inside an explicit BEGIN block you must issue a ROLLBACK before the connection can be reused.

Common Causes

  1. Returning the wrong type from a PL/pgSQL trigger. A function declared RETURNS trigger returns a regular scalar value (integer, text, boolean, etc.) instead of a row. Common mistake: RETURN TRUE; or RETURN 1; instead of RETURN NEW; or RETURN NULL;.

  2. Falling off the end of a PL/pgSQL BEFORE trigger. In PL/pgSQL, falling off the end of a function without an explicit RETURN yields NULL. For a BEFORE trigger this cancels the operation silently, which is usually unintentional and can also trigger the protocol violation error in some call paths.

  3. Returning a row from a different table or composite type. The trigger function returns a row-type variable that does not match the relation the trigger is defined on.

  4. Trigger function written in a procedural language other than PL/pgSQL (PL/Python, PL/Perl, PL/v8, etc.) returning a Python/Perl/JavaScript value that cannot be coerced to a trigger result — for example, returning True from a PL/Python BEFORE trigger instead of returning the row dictionary or None.

  5. Statement-level trigger returning a non-NULL row. Statement-level (FOR EACH STATEMENT) triggers must return NULL. Returning NEW or any row value from a statement-level trigger violates the protocol.

How to Fix trigger_protocol_violated

  1. For row-level BEFORE INSERT or BEFORE UPDATE triggers — return NEW:

    CREATE OR REPLACE FUNCTION my_before_trigger()
    RETURNS trigger
    LANGUAGE plpgsql AS $$
    BEGIN
      -- Modify NEW if needed
      NEW.updated_at := NOW();
      RETURN NEW;  -- Allow the operation to proceed
    END;
    $$;
    
  2. To cancel a row operation silently, return NULL explicitly:

    CREATE OR REPLACE FUNCTION my_conditional_trigger()
    RETURNS trigger
    LANGUAGE plpgsql AS $$
    BEGIN
      IF NEW.status = 'blocked' THEN
        RETURN NULL;  -- Cancels the INSERT/UPDATE for this row
      END IF;
      RETURN NEW;
    END;
    $$;
    
  3. For statement-level triggers, always return NULL:

    CREATE OR REPLACE FUNCTION my_statement_trigger()
    RETURNS trigger
    LANGUAGE plpgsql AS $$
    BEGIN
      -- Do work here
      RETURN NULL;  -- Required for statement-level triggers
    END;
    $$;
    
  4. For PL/Python triggers, return the row dictionary or None:

    CREATE OR REPLACE FUNCTION my_python_trigger()
    RETURNS trigger
    LANGUAGE plpython3u AS $$
      if TD["new"]["status"] == "blocked":
        return None        # Cancels the operation
      return "OK"          # Equivalent to RETURN NEW in PL/Python
    $$;
    

    In PL/Python, returning the string "OK" tells PostgreSQL to use the (possibly modified) TD["new"] row as-is; returning None cancels a BEFORE trigger operation.

  5. Use \df+ <function_name> in psql to inspect the function definition and confirm its return type and body:

    \df+ my_before_trigger
    

    Confirm the return type is trigger and that every code path ends with a valid RETURN.

Additional Information

  • SQLSTATE class 39 (External Routine Invocation Exception) groups errors related to how procedural language functions are called from SQL. Other codes in this class include 39001 (INVALID_SQLSTATE_RETURNED) and 39004 (NULL_VALUE_NOT_ALLOWED).
  • The behavior of trigger return values is documented in the PostgreSQL Trigger Functions section of the manual; the rules differ between PL/pgSQL, PL/Python, PL/Perl, and PL/Tcl.
  • ORMs such as SQLAlchemy and ActiveRecord do not add any special handling for 39P01 — it surfaces as a generic database error. The root cause will always be in the trigger function definition, not in the application query.
  • This error has existed since PostgreSQL added the external routine invocation exception class; it is not specific to any recent version. However, changes to trigger semantics between major versions (e.g., INSTEAD OF triggers added in 9.1) can expose existing trigger functions that have incorrect return paths.

Frequently Asked Questions

Why does my trigger work in development but raise 39P01 in production? This usually means a code path that is rarely exercised in development (a specific UPDATE condition, an edge-case row value) reaches a RETURN statement that returns the wrong type or falls off the end of the function. Review all code paths — especially inside IF/CASE blocks — to ensure every path returns NEW, OLD, or NULL as appropriate.

What is the difference between returning NULL and returning NEW in a BEFORE trigger? Returning NULL from a row-level BEFORE trigger silently suppresses the operation — the INSERT, UPDATE, or DELETE does not happen for that row, and no error is raised. Returning NEW (or a modified copy of NEW) allows the operation to proceed. Confusing these two is a common logic bug; 39P01 itself occurs only when you return an incompatible type, not when you return NULL intentionally.

Can I get this error from a trigger written by a third-party extension? Yes. Extensions that install trigger functions (e.g., audit logging extensions, row-level security helpers) can contain bugs that violate the trigger protocol. After an extension upgrade, check the release notes for trigger function changes, and test against your PostgreSQL version.

How do I find which trigger is causing the error? The full error detail in PostgreSQL logs (with log_min_messages = DEBUG1 or by examining the CONTEXT line in the error) will name the trigger and the relation. You can also query pg_trigger to list all triggers on a table:

SELECT tgname, tgtype, proname
FROM pg_trigger
JOIN pg_proc ON pg_trigger.tgfoid = pg_proc.oid
WHERE tgrelid = 'your_table'::regclass;

Subscribe to the Pulse Newsletter

Get early access to new Pulse features, insightful blogs & exclusive events , webinars, and workshops.

We use cookies to provide an optimized user experience and understand our traffic. To learn more, read our use of cookies; otherwise, please choose 'Accept Cookies' to continue using our website.