PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 30: Functions and triggers

Event triggers: reacting to DDL

Level 6: Design and Security20 min

Ordinary triggers fire on row changes. Event triggers fire on database-wide events such as CREATE, ALTER and DROP, table rewrites and, from PostgreSQL 17, logins. They are how platform teams audit schema changes and enforce rules no migration can skip.

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • Write event trigger functions for ddl_command_end, sql_drop and table_rewrite
  • Use pg_event_trigger_ddl_commands and pg_event_trigger_dropped_objects to see what changed
  • Know the limits, the risks and how to recover from a broken event trigger

conceptThe concept

An event trigger calls a function that RETURNS event_trigger. Events:

EventFiresInspect with
ddl_command_startBefore a DDL command runsTG_TAG only; the object does not exist yet
ddl_command_endAfter a DDL command, in the same transactionpg_event_trigger_ddl_commands()
sql_dropJust before ddl_command_end for commands that drop objectspg_event_trigger_dropped_objects()
table_rewriteBefore ALTER TABLE or ALTER TYPE rewrites a tablepg_event_trigger_table_rewrite_oid() and _reason()
login (17+)When a session connectsSession information functions

WHEN TAG IN ('DROP TABLE', 'ALTER TABLE') filters by command tag. Raising an error in the function aborts the command and its transaction. Event triggers do not fire for commands on shared objects (databases, roles, tablespaces) or for commands on event triggers themselves, and only superusers can create them.

whyWhy it matters

Schema changes can come from migrations, consoles, ORMs and emergency fixes. An event trigger sees all of them, so it is the one place to keep a DDL audit table, block dangerous commands in production, or warn when an ALTER is about to rewrite a 200 GB table under an exclusive lock.

Use caseReal-world scenario

A platform team installs three event triggers in every production database: one writes every DDL command with the role, application name and object identity to an audit table; one rejects DROP TABLE unless a session setting app.allow_drop is on; one raises an error on table rewrites of tables larger than 1 GB outside the maintenance window.

Try itExamples

Run and edit these in the interactive lesson.

Log every DDL command
BEGIN;
CREATE FUNCTION log_ddl() RETURNS event_trigger LANGUAGE plpgsql AS $$
DECLARE r record;
BEGIN
  FOR r IN SELECT * FROM pg_event_trigger_ddl_commands() LOOP
    RAISE NOTICE 'ddl: % % %', r.command_tag, r.object_type, r.object_identity;
  END LOOP;
END $$;
CREATE EVENT TRIGGER audit_ddl ON ddl_command_end EXECUTE FUNCTION log_ddl();
CREATE TABLE et_demo (id int PRIMARY KEY);
ALTER TABLE et_demo ADD COLUMN note text;
SELECT evtname, evtevent, evtenabled FROM pg_event_trigger;
ROLLBACK;
The primary key created an index, and the trigger saw that too. evtenabled O means it fires in origin and local sessions.
A persistent DDL audit table
BEGIN;
CREATE TABLE ddl_audit (
  at          timestamptz NOT NULL DEFAULT now(),
  role_name   text NOT NULL DEFAULT current_user,
  app         text DEFAULT current_setting('application_name', true),
  command     text NOT NULL,
  object_type text,
  identity    text
);
CREATE FUNCTION audit_ddl() RETURNS event_trigger LANGUAGE plpgsql AS $$
BEGIN
  INSERT INTO ddl_audit (command, object_type, identity)
  SELECT command_tag, object_type, object_identity
  FROM pg_event_trigger_ddl_commands()
  WHERE object_identity <> 'public.ddl_audit';
END $$;
CREATE EVENT TRIGGER ddl_audit_trg ON ddl_command_end EXECUTE FUNCTION audit_ddl();
CREATE INDEX orders_status_tmp ON orders (status);
COMMENT ON TABLE orders IS 'customer orders';
SELECT role_name, command, object_type, identity FROM ddl_audit ORDER BY command;
ROLLBACK;
Block DROP TABLE
BEGIN;
CREATE FUNCTION no_drop() RETURNS event_trigger LANGUAGE plpgsql AS $$
BEGIN
  IF coalesce(current_setting('app.allow_drop', true), 'off') <> 'on' THEN
    RAISE EXCEPTION '% is blocked here; SET app.allow_drop = on in a reviewed session', tg_tag;
  END IF;
END $$;
CREATE EVENT TRIGGER guard_drop ON ddl_command_start WHEN TAG IN ('DROP TABLE')
  EXECUTE FUNCTION no_drop();
CREATE TABLE et_tmp (id int);
DROP TABLE et_tmp;
ROLLBACK;
The exception aborts the DROP and the transaction. The session setting is an escape hatch for reviewed maintenance, not a security boundary.
See what a DROP removed
BEGIN;
CREATE FUNCTION log_drops() RETURNS event_trigger LANGUAGE plpgsql AS $$
DECLARE r record;
BEGIN
  FOR r IN SELECT * FROM pg_event_trigger_dropped_objects() LOOP
    RAISE NOTICE 'dropped % % (original: %)', r.object_type, r.object_identity, r.original;
  END LOOP;
END $$;
CREATE EVENT TRIGGER track_drops ON sql_drop EXECUTE FUNCTION log_drops();
CREATE TABLE et_a (id int PRIMARY KEY);
DROP TABLE et_a;
ROLLBACK;
Dependent objects (the index, the row type) are listed too, with original false. Filter on original to see what the user asked to drop.
Warn before a table rewrite
BEGIN;
CREATE FUNCTION warn_rewrite() RETURNS event_trigger LANGUAGE plpgsql AS $$
BEGIN
  RAISE NOTICE 'rewriting % (%), reason %', pg_event_trigger_table_rewrite_oid()::regclass,
    pg_size_pretty(pg_total_relation_size(pg_event_trigger_table_rewrite_oid())),
    pg_event_trigger_table_rewrite_reason();
END $$;
CREATE EVENT TRIGGER rewrite_guard ON table_rewrite EXECUTE FUNCTION warn_rewrite();
CREATE TABLE et_r (id int, note text);
INSERT INTO et_r SELECT g, 'x' FROM generate_series(1, 1000) g;
ALTER TABLE et_r ADD COLUMN flag boolean DEFAULT true;
ALTER TABLE et_r ALTER COLUMN id TYPE bigint;
ROLLBACK;
Adding a column with a constant default is a catalog-only change and does not fire. Changing int to bigint rewrites the table, so the trigger fires once. The reason is a bit mask: 1 persistence change, 2 default value, 4 column type, 8 access method. In production, raise an exception here for large tables.
A login event trigger and recovery
-- PostgreSQL 17+
CREATE FUNCTION check_login() RETURNS event_trigger LANGUAGE plpgsql AS $$
BEGIN
  IF current_user = 'reporting' AND extract(hour FROM now()) BETWEEN 9 AND 17 THEN
    RAISE EXCEPTION 'reporting logins are not allowed during business hours';
  END IF;
END $$;
CREATE EVENT TRIGGER business_hours ON login EXECUTE FUNCTION check_login();

-- if a bug locks everyone out, a superuser connects with:
--   PGOPTIONS='-c event_triggers=off' psql -U postgres
ALTER EVENT TRIGGER business_hours DISABLE;

internalsUnder the hood

Event trigger functions run inside the DDL command's transaction, so an audit row is committed or rolled back with the change itself, and an error rolls the change back. pg_event_trigger_ddl_commands() returns one row per object touched, including implicit ones (a primary key creates an index). The event_triggers setting (PostgreSQL 17+) lets a superuser disable them for a session; before 17, a broken trigger, especially a login trigger, was fixed by starting the server in single-user mode, where event triggers do not fire.

Avoid / preferDo this, not that

Avoid
CREATE FUNCTION audit() RETURNS event_trigger LANGUAGE plpgsql AS $$
BEGIN
  PERFORM dblink_exec('host=audit-db ...', 'INSERT ...');
END $$;
Prefer
-- write to a local audit table in the same transaction,
-- ship it elsewhere asynchronously

An event trigger runs inside every DDL statement. A remote call makes every migration depend on another server and can leave an audit row for a change that was rolled back.

Avoid
CREATE EVENT TRIGGER deny_all ON ddl_command_start EXECUTE FUNCTION no_ddl();  -- no WHEN filter, no escape hatch
Prefer
CREATE EVENT TRIGGER deny_drop ON ddl_command_start
  WHEN TAG IN ('DROP TABLE', 'DROP SCHEMA')
  EXECUTE FUNCTION no_drop();  -- honours a reviewed session setting

A trigger that blocks every DDL also blocks the fix for itself and breaks extension upgrades. Filter by tag and keep a documented override.

PitfallsCommon mistakes

  • Calling pg_event_trigger_ddl_commands() from a ddl_command_start trigger, where it is not available.
  • Forgetting that event triggers do not see CREATE DATABASE, CREATE ROLE or other shared-object commands.
  • Creating a login trigger without testing it, then needing event_triggers = off or single-user mode to get back in.
  • Auditing the audit table: the trigger inserting into it fires again if the audit table itself is altered.

LimitsWhen not to use it

Do not use event triggers for row-level business logic (that is what ordinary triggers are for) or as the only gate for schema changes; code review and migration tooling should catch problems first. On managed services they may require a special role or be unavailable.

SecuritySecurity notes

Event triggers run with the privileges of their function owner on every DDL statement, so only superusers can create them. Keep the functions short, owned by a dedicated admin role, with a fixed search_path. Remember that a superuser can disable them with event_triggers = off, so they enforce policy for normal roles, not against administrators.

ChallengeTry it yourself

Write an event trigger that rejects any new table created without a primary key. Show that a table with a primary key is accepted and one without is rejected.

Show one solution
One solution
BEGIN;
CREATE FUNCTION require_pk() RETURNS event_trigger LANGUAGE plpgsql AS $$
DECLARE r record;
BEGIN
  FOR r IN SELECT * FROM pg_event_trigger_ddl_commands() WHERE command_tag = 'CREATE TABLE' LOOP
    IF NOT EXISTS (SELECT 1 FROM pg_index WHERE indrelid = r.objid AND indisprimary) THEN
      RAISE EXCEPTION 'table % has no primary key', r.object_identity;
    END IF;
  END LOOP;
END $$;
CREATE EVENT TRIGGER pk_required ON ddl_command_end WHEN TAG IN ('CREATE TABLE')
  EXECUTE FUNCTION require_pk();
CREATE TABLE with_pk (id int PRIMARY KEY);
CREATE TABLE without_pk (id int);
ROLLBACK;

InterviewQuestions you should be able to answer

How would you audit every schema change in a PostgreSQL database?
An event trigger on ddl_command_end that inserts pg_event_trigger_ddl_commands() rows with current_user, application_name and a timestamp into an audit table, plus one on sql_drop using pg_event_trigger_dropped_objects(). Because it runs in the same transaction, rolled-back changes leave no audit row. For shared objects such as roles and databases, add log_statement = ddl or pgaudit, since event triggers do not see them.
A login event trigger has a bug and nobody can connect. What do you do?
On PostgreSQL 17 and later, connect as a superuser with the event_triggers setting turned off for the session (for example PGOPTIONS='-c event_triggers=off'), then fix or disable the trigger with ALTER EVENT TRIGGER ... DISABLE. On older versions, or if that fails, stop the server and start it in single-user mode, where event triggers do not fire.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation