Ad – 728×90
🔧 Advanced SQL

SQL Triggers – Automating Actions on Data Changes

A trigger is a special type of stored procedure that fires automatically when a specified data event occurs on a table — INSERT, UPDATE, or DELETE. Triggers run without being called explicitly. They're powerful for audit logging, timestamp automation, and enforcing complex business rules, but they introduce hidden logic that must be used with care.

⏱️ 20 min read 🎯 Advanced 📅 Updated 2026

What Are Triggers?

A trigger is a database object associated with a table that automatically executes a block of SQL when a specific DML event (INSERT, UPDATE, or DELETE) occurs. Unlike stored procedures, triggers are never called directly — they fire implicitly as a reaction to data changes.

ℹ️
Trigger events

Every trigger is tied to exactly one table and one event: INSERT, UPDATE, or DELETE. It fires either BEFORE the change is written (BEFORE trigger) or AFTER it has been written (AFTER trigger). You choose based on whether you need to inspect or modify the data before it is saved, or react to it after it is committed.

Creating a Trigger

The general syntax is: CREATE TRIGGER name BEFORE|AFTER INSERT|UPDATE|DELETE ON table FOR EACH ROW BEGIN ... END

SQL – MySQL
DELIMITER $$

-- AFTER INSERT trigger: log new employee to audit table
CREATE TRIGGER trg_employee_insert
AFTER INSERT ON employees
FOR EACH ROW
BEGIN
  INSERT INTO audit_log (table_name, operation, record_id, changed_at, changed_by)
  VALUES ('employees', 'INSERT', NEW.id, NOW(), CURRENT_USER());
END$$

-- BEFORE UPDATE trigger: prevent salary from being decreased
CREATE TRIGGER trg_no_salary_decrease
BEFORE UPDATE ON employees
FOR EACH ROW
BEGIN
  IF NEW.salary < OLD.salary THEN
    SIGNAL SQLSTATE '45000'
      SET MESSAGE_TEXT = 'Salary cannot be decreased via UPDATE';
  END IF;
END$$

-- AFTER DELETE trigger: archive deleted record
CREATE TRIGGER trg_employee_delete
AFTER DELETE ON employees
FOR EACH ROW
BEGIN
  INSERT INTO employees_archive
    (id, name, department, salary, deleted_at)
  VALUES
    (OLD.id, OLD.name, OLD.department, OLD.salary, NOW());
END$$

DELIMITER ;

BEFORE vs AFTER Triggers

TimingFires WhenCan Modify NEW Row?Typical Use Cases
BEFOREBefore the row is written to diskYes — set NEW.col = valueValidation, data normalization, computed columns, rejecting invalid values
AFTERAfter the row is successfully writtenNo — row already savedAudit logging, cascading updates to other tables, sending notifications
SQL – MySQL
DELIMITER $$

-- BEFORE INSERT: normalize email to lowercase and auto-set created_at
CREATE TRIGGER trg_normalize_user
BEFORE INSERT ON users
FOR EACH ROW
BEGIN
  SET NEW.email      = LOWER(TRIM(NEW.email));
  SET NEW.created_at = COALESCE(NEW.created_at, NOW());
END$$

DELIMITER ;

NEW and OLD Row References

Inside a trigger body, two special pseudo-rows give you access to the data being changed:

ReferenceAvailable InMeaning
NEWINSERT, UPDATEThe row as it will be after the operation. In BEFORE triggers you can modify NEW columns.
OLDUPDATE, DELETEThe row as it was before the operation. Read-only — you cannot modify OLD.
SQL – MySQL
DELIMITER $$

-- Full audit trail: record what changed, old value, and new value
CREATE TRIGGER trg_salary_audit
AFTER UPDATE ON employees
FOR EACH ROW
BEGIN
  IF OLD.salary != NEW.salary THEN
    INSERT INTO salary_changes
      (employee_id, old_salary, new_salary, changed_at, changed_by)
    VALUES
      (NEW.id, OLD.salary, NEW.salary, NOW(), CURRENT_USER());
  END IF;
END$$

DELIMITER ;
Ad – 336×280

Practical Use Cases

1. Automatic updated_at timestamp

SQL – MySQL
DELIMITER $$

CREATE TRIGGER trg_updated_at
BEFORE UPDATE ON products
FOR EACH ROW
BEGIN
  SET NEW.updated_at = NOW();
END$$

DELIMITER ;

-- Note: In MySQL you can also achieve this with:
-- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
-- But a trigger gives you more control (e.g. conditional updates).

2. Cascade stock deduction on order

SQL – MySQL
DELIMITER $$

CREATE TRIGGER trg_deduct_stock
AFTER INSERT ON order_items
FOR EACH ROW
BEGIN
  UPDATE products
  SET stock = stock - NEW.quantity
  WHERE id = NEW.product_id;

  -- Warn (but allow) if stock goes negative
  IF (SELECT stock FROM products WHERE id = NEW.product_id) < 0 THEN
    INSERT INTO stock_alerts (product_id, alert_time)
    VALUES (NEW.product_id, NOW());
  END IF;
END$$

DELIMITER ;

3. PostgreSQL trigger (PL/pgSQL)

SQL – PostgreSQL
-- Step 1: Create the trigger function
CREATE OR REPLACE FUNCTION update_modified_column()
RETURNS TRIGGER AS $$
BEGIN
  NEW.updated_at = NOW();
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Step 2: Attach the function to the table as a trigger
CREATE TRIGGER trg_set_updated_at
BEFORE UPDATE ON employees
FOR EACH ROW
EXECUTE FUNCTION update_modified_column();

-- Drop a trigger (PostgreSQL)
DROP TRIGGER trg_set_updated_at ON employees;
ℹ️
PostgreSQL uses trigger functions

In PostgreSQL, the logic goes in a separate trigger function that returns TRIGGER. The CREATE TRIGGER statement then attaches that function to a table event. This allows you to reuse the same trigger function on multiple tables — a cleaner design than MySQL's inline approach.

Cautions: When Not to Use Triggers

  • Hidden logic — triggers fire silently. A developer querying the table won't see the trigger logic unless they specifically look for it, making the system harder to understand and debug.
  • Performance impact — every INSERT/UPDATE/DELETE on the table executes the trigger. A poorly written trigger can add significant overhead to write-heavy tables.
  • Hard to debug — trigger errors surface as cryptic database errors, and stepping through trigger logic in a debugger is not as straightforward as application code.
  • Cascading chains — a trigger that modifies another table can fire a second trigger, which fires a third, and so on. These chains are extremely hard to reason about and can cause unexpected behavior or infinite loops.
  • Testing difficulty — triggers make unit testing harder because test code must be aware of all the side effects that fire on simple INSERT/UPDATE operations.
⚠️
Use triggers sparingly

Triggers are best suited for infrastructure concerns (audit trails, updated_at stamps) that should happen for every write, regardless of application. Avoid encoding business logic in triggers — put that in your application's service layer or in explicit stored procedures called deliberately.

Summary

📋 Summary

  • A trigger fires automatically on INSERT, UPDATE, or DELETE — you never call it directly.
  • BEFORE triggers run before the write and can modify NEW column values; AFTER triggers run after and are read-only.
  • NEW refers to the incoming row (INSERT/UPDATE); OLD refers to the existing row (UPDATE/DELETE).
  • Great use cases: audit logging, automatic timestamps, cascading stock updates.
  • In PostgreSQL, triggers use a two-step approach: create a trigger function, then attach it with CREATE TRIGGER ... EXECUTE FUNCTION.
  • Avoid complex business logic in triggers — they are hidden, hard to debug, and can cascade unexpectedly.

FAQ

Can a trigger call a stored procedure? +

Yes — a trigger body can call a stored procedure using CALL procedure_name(...) in MySQL. In PostgreSQL, a trigger function can call other functions. However, calling long-running procedures from triggers is risky because triggers run inside the transaction of the triggering statement, which means locks are held longer than expected.

Can I have multiple triggers on the same table and event? +

In PostgreSQL, yes — multiple triggers can fire for the same event on the same table, and they execute in alphabetical order by trigger name. In MySQL, each table can have at most one trigger for each combination of event (INSERT/UPDATE/DELETE) and timing (BEFORE/AFTER) — so a maximum of 6 triggers per table. In MySQL 5.7+ you can use the FOLLOWS and PRECEDES clauses to order triggers on the same event (MySQL supports multiple triggers per event from 5.7).

Do triggers fire for bulk operations like DELETE with no WHERE? +

In MySQL and PostgreSQL, FOR EACH ROW triggers fire once per affected row — so a DELETE that removes 10,000 rows fires the trigger 10,000 times. This can be extremely slow for bulk operations. If you run large bulk deletes/updates frequently, consider disabling triggers temporarily (if the database supports it) or using batch stored procedures instead of row-level triggers.

How do I list all triggers in a database? +

In MySQL: SHOW TRIGGERS FROM my_database; or query information_schema.TRIGGERS. In PostgreSQL: SELECT trigger_name, event_manipulation, event_object_table FROM information_schema.triggers WHERE trigger_schema = 'public';. Both return the trigger name, the event it fires on, the associated table, and timing information.