PL/pgSQL TRUNCATE Triggers and DROP TRIGGER
Master both TRUNCATE triggers (statement-level only, no NEW/OLD) and the DROP TRIGGER statement for permanent removal. Critical correction: original used RAISE NOTICE then claimed it 'aborts the operation' — NOTICE doesn't abort, RAISE EXCEPTION does. Also covers why ON table_name is required (trigger names are local to tables in PG), idempotent drops with IF EXISTS, and the DROP vs DISABLE decision.
This page covers two related topics: triggers that fire on TRUNCATE events, and the DROP TRIGGER statement for removing triggers altogether. Both are about lifecycle — TRUNCATE triggers respond to a wholesale data lifecycle event; DROP TRIGGER ends a trigger's own lifecycle.
TRUNCATE TABLE is the fast bulk-delete operation in PostgreSQL — it removes all rows from a table without scanning them, by simply re-initializing the underlying file. It's much faster than DELETE on a large table. But that speed comes with constraints:
| Property | DELETE | TRUNCATE |
|---|---|---|
| Per-row processing | Yes — fires DELETE triggers, returns row count | No — wholesale operation |
| Speed on large tables | Slow — proportional to row count | Fast — constant time |
| WHERE clause | Yes — selective deletion | No — all rows or nothing |
| Triggers fired | BEFORE/AFTER DELETE (row-level) | BEFORE/AFTER TRUNCATE (statement-level only) |
| OLD/NEW available | OLD in DELETE triggers | Neither — no per-row context |
The big takeaway: TRUNCATE does not fire DELETE triggers. If you have audit-logging on DELETE, a TRUNCATE bypasses it entirely — you'll lose all rows with no audit trail. To handle TRUNCATE you need a separate BEFORE TRUNCATE or AFTER TRUNCATE trigger.
FOR EACH ROW for a TRUNCATE trigger — there's no row-level context to fire against. The clause is FOR EACH STATEMENT, or just omit it (statement is the default). Inside the function, NEW and OLD are not available because there's no current row.DROP TABLE IF EXISTS truncate_audit;
DROP TABLE IF EXISTS companies;
CREATE TABLE companies (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL
);
CREATE TABLE truncate_audit (
id SERIAL PRIMARY KEY,
table_name TEXT,
truncated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
truncated_by TEXT DEFAULT CURRENT_USER
);
INSERT INTO companies (name) VALUES
('Apple'),
('Microsoft'),
('Google');
The protective pattern: prevent applications from truncating a table at all. Use RAISE EXCEPTION to abort:
CREATE OR REPLACE FUNCTION refuse_truncate()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION 'Truncating the % table is not allowed', TG_TABLE_NAME;
RETURN NULL; -- never reached, but keeps the function valid
END;
$$;
CREATE TRIGGER companies_no_truncate
BEFORE TRUNCATE ON companies
FOR EACH STATEMENT
EXECUTE FUNCTION refuse_truncate();
-- Try it
TRUNCATE TABLE companies;
RAISE NOTICE here, then claimed in the conclusion that the trigger "raises an exception that aborts the TRUNCATE operation." A NOTICE doesn't abort anything — it just prints a message and execution continues normally. The TRUNCATE would proceed and wipe the table. To genuinely abort, you must use RAISE EXCEPTION as shown above. This is the kind of subtle inconsistency that bites in production.A more practical pattern: don't refuse the truncation, but log it before it happens. The companies table will still be emptied, but we'll have a record:
DROP TRIGGER IF EXISTS companies_no_truncate ON companies;
CREATE OR REPLACE FUNCTION log_truncate()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
INSERT INTO truncate_audit (table_name)
VALUES (TG_TABLE_NAME);
RETURN NULL;
END;
$$;
CREATE TRIGGER companies_log_truncate
BEFORE TRUNCATE ON companies
FOR EACH STATEMENT
EXECUTE FUNCTION log_truncate();
-- Truncate the table
TRUNCATE TABLE companies;
-- Verify both: companies is empty, audit has a record
SELECT * FROM companies;
SELECT * FROM truncate_audit;
Companies table emptied, but the audit table holds a record of when and by whom. Note that we don't have row-level data to log here — TRUNCATE doesn't expose individual rows to its triggers.
INSERT INTO companies_archive SELECT * FROM companies; — this runs before the truncate completes. After the function returns, the original table is wiped, but the archive has everything.To remove a trigger entirely, use DROP TRIGGER. Unlike disabling, this is permanent — the trigger definition is gone from the database.
| Clause | Purpose |
|---|---|
IF EXISTS | Issues a NOTICE instead of an error when the trigger doesn't exist. Use in deployment scripts. |
ON table_name | Required — trigger names are local to their table in PostgreSQL, so the table must be specified. |
CASCADE | Drops dependent objects too (rare for triggers). |
RESTRICT | Default — refuses if anything depends on the trigger. |
ON table_name is required. In the SQL standard, trigger names are global within a database. PostgreSQL departs from that — trigger names are local to their table. Two different tables can both have a trigger named after_insert without conflict. The trade-off: when dropping, PostgreSQL needs you to specify which table.DROP TABLE IF EXISTS staff;
CREATE TABLE staff (
staff_id SERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL,
email VARCHAR(100)
);
-- Validator function: refuse usernames shorter than 8 characters
CREATE OR REPLACE FUNCTION check_staff_username()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
-- Check NULL first; otherwise length(NULL) returns NULL and the comparison silently fails
IF NEW.username IS NULL THEN
RAISE EXCEPTION 'username cannot be NULL';
END IF;
IF length(NEW.username) < 8 THEN
RAISE EXCEPTION 'username must be at least 8 characters (got %)', NEW.username;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER username_check
BEFORE INSERT OR UPDATE ON staff
FOR EACH ROW
EXECUTE FUNCTION check_staff_username();
IF length(NEW.username) < 8 OR NEW.username IS NULL THEN. That's wrong order: if username is NULL, length(NULL) returns NULL, so NULL < 8 evaluates to NULL. PostgreSQL treats NULL in a boolean context as not-true, so the IF is skipped and the NULL slips through. The fix is to check NULL first, as shown above. The original also referenced NEW.NAME (uppercase) when the column is username — stale code. Both bugs fixed.DROP TRIGGER username_check ON staff;
Trigger removed. The function (check_staff_username) still exists — dropping a trigger doesn't drop the function it called. To remove the function too, use DROP FUNCTION check_staff_username(); separately.
Deployment scripts should never error on the first run when the trigger doesn't exist yet. Use IF EXISTS:
DROP TRIGGER IF EXISTS username_check ON staff;
DROP TRIGGER IF EXISTS username_check ON staff; -- run twice; second is a no-op
Each run emits a NOTICE but doesn't error — the script can run repeatedly without breaking.
| Operation | Reversible | Definition preserved | When to use |
|---|---|---|---|
DROP TRIGGER | No — must recreate | No — gone from the database | Permanent removal |
DISABLE TRIGGER | Yes — re-enable any time | Yes — definition stays | Temporary suspension (bulk import, debugging) |
If you find yourself disabling a trigger and never re-enabling it, drop it instead. A permanently-disabled trigger sits in the schema confusing readers ("does this run? why is it here?") — clarity is better than archeology.
- TRUNCATE triggers must be statement-level. No
FOR EACH ROW, noNEWorOLDrecords. - Use
RAISE EXCEPTION, notRAISE NOTICE, when you mean to abort. NOTICE just prints a message — execution continues. - TRUNCATE doesn't fire DELETE triggers. If you have audit-on-DELETE logic, add a separate TRUNCATE trigger or your audit will have gaps.
- Use
IF EXISTSin DROP TRIGGER scripts — first runs won't error; reruns won't break. - Always specify
ON table_namewhen dropping. Trigger names are scoped to their table. - Check NULL before length-based comparisons.
length(NULL)returns NULL, which makeslength(NEW.col) < N OR NEW.col IS NULLsilently fail to catch NULL inputs. - Drop a trigger when you mean to remove it permanently. Disable for temporary suspension; drop when the trigger has no future.
- TRUNCATE triggers fire on
TRUNCATE TABLE. They're statement-level only — no NEW or OLD, no row-level scope. - BEFORE TRUNCATE can refuse the operation (RAISE EXCEPTION) or do bookkeeping like audit logging or copying rows to an archive.
- AFTER TRUNCATE fires after the rows are gone — useful for reset-of-related-counters logic.
- TRUNCATE does not fire DELETE triggers. Plan for it explicitly.
DROP TRIGGER name ON tablepermanently removes a trigger. The function it called is unaffected — drop separately if needed.- Use
IF EXISTSin deployment scripts for idempotency. - Trigger names in PostgreSQL are local to their table — the
ON table_nameclause is required when dropping.