Event triggers: reacting to DDL
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.
- 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:
| Event | Fires | Inspect with |
|---|---|---|
ddl_command_start | Before a DDL command runs | TG_TAG only; the object does not exist yet |
ddl_command_end | After a DDL command, in the same transaction | pg_event_trigger_ddl_commands() |
sql_drop | Just before ddl_command_end for commands that drop objects | pg_event_trigger_dropped_objects() |
table_rewrite | Before ALTER TABLE or ALTER TYPE rewrites a table | pg_event_trigger_table_rewrite_oid() and _reason() |
login (17+) | When a session connects | Session 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.
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;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;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;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;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;-- 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
CREATE FUNCTION audit() RETURNS event_trigger LANGUAGE plpgsql AS $$
BEGIN
PERFORM dblink_exec('host=audit-db ...', 'INSERT ...');
END $$;-- 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.
CREATE EVENT TRIGGER deny_all ON ddl_command_start EXECUTE FUNCTION no_ddl(); -- no WHEN filter, no escape hatch
CREATE EVENT TRIGGER deny_drop ON ddl_command_start
WHEN TAG IN ('DROP TABLE', 'DROP SCHEMA')
EXECUTE FUNCTION no_drop(); -- honours a reviewed session settingA 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
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?
A login event trigger has a bug and nobody can connect. What do you do?
QuizCheck your understanding
4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.