PostgreSQL TRUNCATE+DROP PostgreSQL · Triggers · TRUNCATE+DROP

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:

PropertyDELETETRUNCATE
Per-row processingYes — fires DELETE triggers, returns row countNo — wholesale operation
Speed on large tablesSlow — proportional to row countFast — constant time
WHERE clauseYes — selective deletionNo — all rows or nothing
Triggers firedBEFORE/AFTER DELETE (row-level)BEFORE/AFTER TRUNCATE (statement-level only)
OLD/NEW availableOLD in DELETE triggersNeither — 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.

CREATE [OR REPLACE] FUNCTION trigger_function_name() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN -- TRUNCATE triggers have no NEW or OLD; statement-level only RETURN NULL; END; $$; CREATE TRIGGER trigger_name {BEFORE | AFTER} TRUNCATE ON table_name FOR EACH STATEMENT EXECUTE FUNCTION trigger_function_name();
⚠️ TRUNCATE triggers are statement-level only. You cannot write 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.
PL/pgSQL — Setup
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');
Example 1 — BEFORE TRUNCATE: Refuse the Operation

The protective pattern: prevent applications from truncating a table at all. Use RAISE EXCEPTION to abort:

PL/pgSQL — refuse truncate
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;
OUTPUT
psql — refused
postgres=#TRUNCATE TABLE companies;
ERROR: Truncating the companies table is not allowed
CONTEXT: PL/pgSQL function refuse_truncate() line 3 at RAISE
⚠️ The original tutorial used 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.
Example 2 — BEFORE TRUNCATE: Audit Log Before Wiping

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:

PL/pgSQL — log truncate
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;
OUTPUT
psql — truncate logged
postgres=#TRUNCATE TABLE companies;
TRUNCATE TABLE
postgres=#SELECT * FROM companies;
id | name
----+------
(0 rows)
postgres=#SELECT * FROM truncate_audit;
id | table_name | truncated_at | truncated_by
----+------------+----------------------------+--------------
1 | companies | 2026-05-03 14:15:09.622+00 | postgres
(1 row)

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.

💡 To preserve row data before TRUNCATE, copy the rows out before the actual truncation runs. Inside the BEFORE TRUNCATE function, do 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.

DROP TRIGGER [IF EXISTS] trigger_name ON table_name [CASCADE | RESTRICT];
ClausePurpose
IF EXISTSIssues a NOTICE instead of an error when the trigger doesn't exist. Use in deployment scripts.
ON table_nameRequired — trigger names are local to their table in PostgreSQL, so the table must be specified.
CASCADEDrops dependent objects too (rare for triggers).
RESTRICTDefault — refuses if anything depends on the trigger.
📌 Why 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.
Example 3 — Set Up a Trigger to Drop
PL/pgSQL — Setup with username validator
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();
⚠️ The original tutorial's check was 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.
Example 4 — Drop the Trigger
PL/pgSQL — basic drop
DROP TRIGGER username_check ON staff;
OUTPUT
psql — dropped
postgres=#DROP TRIGGER username_check ON staff;
DROP TRIGGER

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.

Example 5 — Idempotent Drop with IF EXISTS

Deployment scripts should never error on the first run when the trigger doesn't exist yet. Use IF EXISTS:

PL/pgSQL — idempotent drop
DROP TRIGGER IF EXISTS username_check ON staff;
DROP TRIGGER IF EXISTS username_check ON staff;   -- run twice; second is a no-op
OUTPUT
psql — idempotent
postgres=#DROP TRIGGER IF EXISTS username_check ON staff;
NOTICE: trigger "username_check" for relation "staff" does not exist, skipping
DROP TRIGGER
postgres=#DROP TRIGGER IF EXISTS username_check ON staff;
NOTICE: trigger "username_check" for relation "staff" does not exist, skipping
DROP TRIGGER

Each run emits a NOTICE but doesn't error — the script can run repeatedly without breaking.

OperationReversibleDefinition preservedWhen to use
DROP TRIGGERNo — must recreateNo — gone from the databasePermanent removal
DISABLE TRIGGERYes — re-enable any timeYes — definition staysTemporary 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.

  1. TRUNCATE triggers must be statement-level. No FOR EACH ROW, no NEW or OLD records.
  2. Use RAISE EXCEPTION, not RAISE NOTICE, when you mean to abort. NOTICE just prints a message — execution continues.
  3. TRUNCATE doesn't fire DELETE triggers. If you have audit-on-DELETE logic, add a separate TRUNCATE trigger or your audit will have gaps.
  4. Use IF EXISTS in DROP TRIGGER scripts — first runs won't error; reruns won't break.
  5. Always specify ON table_name when dropping. Trigger names are scoped to their table.
  6. Check NULL before length-based comparisons. length(NULL) returns NULL, which makes length(NEW.col) < N OR NEW.col IS NULL silently fail to catch NULL inputs.
  7. 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 table permanently removes a trigger. The function it called is unaffected — drop separately if needed.
  • Use IF EXISTS in deployment scripts for idempotency.
  • Trigger names in PostgreSQL are local to their table — the ON table_name clause is required when dropping.