| |

SQL 87 🛢️ Database Triggers: BEFORE, AFTER, and INSTEAD OF

⏱️ Estimated read: 19 min | |

A trigger is a piece of code the database runs automatically when a table changes. The application does not call it; the INSERT, the UPDATE, or the DELETE calls it. The trigger is the mechanism for the rules that must hold regardless of which application or which developer performs the change. The audit log, the computed column, the validation, the cascading update, and the view modification are the classic uses. The three timing options are BEFORE, AFTER, and INSTEAD OF, and each has a different role: the BEFORE validates or modifies the row before it is written, the AFTER records or cascades after the change is committed, and the INSTEAD OF intercepts the operation on a view and replaces it with the underlying table work.

Key point: A BEFORE trigger can modify the row being written, and the modification is what the database stores. An AFTER trigger cannot modify the row, and it runs after the change is visible. An INSTEAD OF trigger is only on a view, and it replaces the default action entirely.


Why triggers exist

The consistency problem. A rule must hold for every change, regardless of the code path. The application validates the input, but the bulk import, the migration script, and the ad-hoc update do not. The trigger enforces the rule at the database, and the rule holds for every path.

The audit problem. The record of who changed what and when must be accurate. The application-level audit is easy to forget, and the direct SQL bypasses it. The AFTER trigger writes the audit row for every change, and the trail is complete.

The computed-column problem. A derived column must stay in sync. The updated_at timestamp, the normalized email, the full name, the search vector. The BEFORE trigger computes the value, and the column is correct without the application’s help.

The cascade problem. A change to one table must propagate to another. The delete of the parent must delete the children, or the order total must update the summary. The AFTER trigger propagates the change.

The view-modification problem. A view that joins several tables is not directly updatable. The INSTEAD OF trigger intercepts the INSERT, the UPDATE, or the DELETE on the view and applies the changes to the underlying tables.

The constraint problem. A complex rule that the CHECK constraint cannot express. The cross-row rule, the cross-table rule, the temporal rule. The BEFORE trigger raises the exception, and the rule holds.

The performance problem. The trigger runs inside the transaction, and the work is part of the change. The application does not make a separate round trip, and the change is atomic with the trigger’s work.


a. The BEFORE triggers

The BEFORE trigger runs before the row is written. It can read the old and the new values, and it can modify the new value. The modification is what the database stores.

The PostgreSQL BEFORE trigger

CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at := NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_set_updated_at
BEFORE UPDATE ON orders
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();

The NEW.updated_at := NOW() sets the value, and the RETURN NEW returns the modified row. The database stores the modified row.

The SQL Server INSTEAD OF and the AFTER

SQL Server has the INSTEAD OF and the AFTER triggers, and it does not have the BEFORE. The INSTEAD OF on a table is the closest alternative.

CREATE TRIGGER trg_set_updated_at
ON orders
INSTEAD OF UPDATE
AS
BEGIN
    SET NOCOUNT ON;

    UPDATE orders
    SET updated_at = GETDATE()
    FROM inserted i
    WHERE orders.id = i.id;
END;

The INSTEAD OF trigger intercepts the update and performs the modified update.

The MySQL BEFORE trigger

DELIMITER //

CREATE TRIGGER trg_set_updated_at
BEFORE UPDATE ON orders
FOR EACH ROW
BEGIN
    SET NEW.updated_at = NOW();
END //

DELIMITER ;

The SET NEW.updated_at = NOW() sets the value, and the database stores it.

The validation trigger

CREATE OR REPLACE FUNCTION validate_order()
RETURNS TRIGGER AS $$
BEGIN
    IF NEW.total < 0 THEN
        RAISE EXCEPTION 'The total must be non-negative'
            USING ERRCODE = '23514';
    END IF;

    IF NEW.customer_id IS NULL THEN
        RAISE EXCEPTION 'The customer is required'
            USING ERRCODE = '23502';
    END IF;

    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_validate_order
BEFORE INSERT OR UPDATE ON orders
FOR EACH ROW
EXECUTE FUNCTION validate_order();

The RAISE EXCEPTION aborts the change, and the trigger enforces the rule.

The BEFORE trigger table

DatabaseSyntax
PostgreSQLBEFORE UPDATE ... EXECUTE FUNCTION
SQL ServerThe INSTEAD OF alternative
MySQLBEFORE UPDATE ON ... FOR EACH ROW
OracleBEFORE UPDATE ON ... FOR EACH ROW

The OLD and the NEW

The BEFORE trigger reads the OLD and the NEW. The OLD is the row before the change, and the NEW is the row being written.

OperationOLDNEW
INSERTnullthe new row
UPDATEthe old rowthe new row
DELETEthe old rownull

The BEFORE trigger modifies the NEW for the INSERT and the UPDATE. For the DELETE, the NEW is not available, and the OLD is the row being removed.


b. The AFTER triggers

The AFTER trigger runs after the row is written. The change is visible, and the trigger cannot modify the row. It reads the OLD and the NEW and performs the follow-up work.

The audit trigger

CREATE OR REPLACE FUNCTION audit_order_change()
RETURNS TRIGGER AS $$
BEGIN
    IF TG_OP = 'INSERT' THEN
        INSERT INTO order_audit (order_id, action, new_total, changed_by, changed_at)
        VALUES (NEW.id, 'INSERT', NEW.total, current_user, NOW());
        RETURN NEW;
    ELSIF TG_OP = 'UPDATE' THEN
        INSERT INTO order_audit (order_id, action, old_total, new_total, changed_by, changed_at)
        VALUES (NEW.id, 'UPDATE', OLD.total, NEW.total, current_user, NOW());
        RETURN NEW;
    ELSIF TG_OP = 'DELETE' THEN
        INSERT INTO order_audit (order_id, action, old_total, changed_by, changed_at)
        VALUES (OLD.id, 'DELETE', OLD.total, current_user, NOW());
        RETURN OLD;
    END IF;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_audit_order
AFTER INSERT OR UPDATE OR DELETE ON orders
FOR EACH ROW
EXECUTE FUNCTION audit_order_change();

The TG_OP is the operation. The AFTER trigger writes the audit row for the three operations.

The TG_OP and the trigger context

VariablePurpose
TG_OPThe operation (INSERT, UPDATE, DELETE)
TG_TABLE_NAMEThe table name
TG_WHENThe timing (BEFORE, AFTER, INSTEAD OF)
OLDThe old row
NEWThe new row

The cascade trigger

CREATE OR REPLACE FUNCTION update_order_total()
RETURNS TRIGGER AS $$
BEGIN
    UPDATE orders
    SET total = (
        SELECT COALESCE(SUM(quantity * price), 0)
        FROM order_items oi
        JOIN products p ON p.id = oi.product_id
        WHERE oi.order_id = COALESCE(NEW.order_id, OLD.order_id)
    )
    WHERE id = COALESCE(NEW.order_id, OLD.order_id);

    RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_update_order_total
AFTER INSERT OR UPDATE OR DELETE ON order_items
FOR EACH ROW
EXECUTE FUNCTION update_order_total();

The trigger recomputes the order total when the line items change.

The SQL Server AFTER trigger

CREATE TRIGGER trg_audit_order
ON orders
AFTER INSERT, UPDATE, DELETE
AS
BEGIN
    SET NOCOUNT ON;

    INSERT INTO order_audit (order_id, action, changed_by, changed_at)
    SELECT
        COALESCE(i.id, d.id),
        CASE
            WHEN i.id IS NOT NULL AND d.id IS NULL THEN 'INSERT'
            WHEN i.id IS NOT NULL AND d.id IS NOT NULL THEN 'UPDATE'
            WHEN i.id IS NULL AND d.id IS NOT NULL THEN 'DELETE'
        END,
        SUSER_SNAME(),
        GETDATE()
    FROM inserted i
    FULL OUTER JOIN deleted d ON i.id = d.id;
END;

The inserted and the deleted are the SQL Server pseudo-tables. The inserted holds the new rows, and the deleted holds the old rows.

The MySQL AFTER trigger

DELIMITER //

CREATE TRIGGER trg_audit_order
AFTER UPDATE ON orders
FOR EACH ROW
BEGIN
    INSERT INTO order_audit (order_id, action, old_total, new_total, changed_at)
    VALUES (NEW.id, 'UPDATE', OLD.total, NEW.total, NOW());
END //

DELIMITER ;

The MySQL AFTER trigger runs once per row with the FOR EACH ROW.

The AFTER trigger table

DatabaseSyntax
PostgreSQLAFTER INSERT OR UPDATE ... EXECUTE FUNCTION
SQL ServerAFTER INSERT, UPDATE, DELETE AS ...
MySQLAFTER UPDATE ON ... FOR EACH ROW
OracleAFTER UPDATE ON ... FOR EACH ROW

c. The INSTEAD OF triggers

The INSTEAD OF trigger is only on a view. It intercepts the INSERT, the UPDATE, or the DELETE on the view and replaces the default action.

The view-modification problem

A view that joins the users and the profiles is not directly updatable. The INSERT into the view is ambiguous: which table receives the row? The INSTEAD OF trigger defines the mapping.

CREATE VIEW user_profiles AS
SELECT u.id, u.email, p.bio, p.avatar_url
FROM users u
LEFT JOIN profiles p ON p.user_id = u.id;

The view joins the two tables, and the insert must be split.

The INSTEAD OF INSERT trigger

CREATE OR REPLACE FUNCTION insert_user_profile()
RETURNS TRIGGER AS $$
DECLARE
    v_user_id INT;
BEGIN
    INSERT INTO users (email) VALUES (NEW.email)
    RETURNING id INTO v_user_id;

    INSERT INTO profiles (user_id, bio, avatar_url)
    VALUES (v_user_id, NEW.bio, NEW.avatar_url);

    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_insert_user_profile
INSTEAD OF INSERT ON user_profiles
FOR EACH ROW
EXECUTE FUNCTION insert_user_profile();

The trigger splits the insert into the two underlying tables.

The INSTEAD OF UPDATE trigger

CREATE OR REPLACE FUNCTION update_user_profile()
RETURNS TRIGGER AS $$
BEGIN
    UPDATE users SET email = NEW.email WHERE id = OLD.id;
    UPDATE profiles SET bio = NEW.bio, avatar_url = NEW.avatar_url
    WHERE user_id = OLD.id;

    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_update_user_profile
INSTEAD OF UPDATE ON user_profiles
FOR EACH ROW
EXECUTE FUNCTION update_user_profile();

The trigger applies the update to the two tables.

The INSTEAD OF DELETE trigger

CREATE OR REPLACE FUNCTION delete_user_profile()
RETURNS TRIGGER AS $$
BEGIN
    DELETE FROM profiles WHERE user_id = OLD.id;
    DELETE FROM users WHERE id = OLD.id;

    RETURN OLD;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_delete_user_profile
INSTEAD OF DELETE ON user_profiles
FOR EACH ROW
EXECUTE FUNCTION delete_user_profile();

The trigger deletes the rows from the two tables.

The SQL Server INSTEAD OF trigger

CREATE TRIGGER trg_insert_user_profile
ON user_profiles
INSTEAD OF INSERT
AS
BEGIN
    SET NOCOUNT ON;

    DECLARE @user_id INT;

    INSERT INTO users (email)
    SELECT email FROM inserted;

    SET @user_id = SCOPE_IDENTITY();

    INSERT INTO profiles (user_id, bio, avatar_url)
    SELECT @user_id, bio, avatar_url FROM inserted;
END;

The SQL Server INSTEAD OF trigger uses the inserted pseudo-table.

The INSTEAD OF trigger table

DatabaseSyntax
PostgreSQLINSTEAD OF INSERT ON view ...
SQL ServerINSTEAD OF INSERT ON view AS ...
MySQLNot supported
OracleINSTEAD OF INSERT ON view ...

Complete Example Session

-- ============================================
-- PART 1: THE BEFORE TRIGGER
-- ============================================

CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at := NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_set_updated_at
BEFORE UPDATE ON orders
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();

-- ============================================
-- PART 2: THE VALIDATION TRIGGER
-- ============================================

CREATE OR REPLACE FUNCTION validate_order()
RETURNS TRIGGER AS $$
BEGIN
    IF NEW.total < 0 THEN
        RAISE EXCEPTION 'The total must be non-negative' USING ERRCODE = '23514';
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_validate_order
BEFORE INSERT OR UPDATE ON orders
FOR EACH ROW
EXECUTE FUNCTION validate_order();

-- ============================================
-- PART 3: THE AUDIT TRIGGER
-- ============================================

CREATE OR REPLACE FUNCTION audit_order_change()
RETURNS TRIGGER AS $$
BEGIN
    IF TG_OP = 'INSERT' THEN
        INSERT INTO order_audit (order_id, action, new_total, changed_at)
        VALUES (NEW.id, 'INSERT', NEW.total, NOW());
        RETURN NEW;
    ELSIF TG_OP = 'UPDATE' THEN
        INSERT INTO order_audit (order_id, action, old_total, new_total, changed_at)
        VALUES (NEW.id, 'UPDATE', OLD.total, NEW.total, NOW());
        RETURN NEW;
    ELSIF TG_OP = 'DELETE' THEN
        INSERT INTO order_audit (order_id, action, old_total, changed_at)
        VALUES (OLD.id, 'DELETE', OLD.total, NOW());
        RETURN OLD;
    END IF;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_audit_order
AFTER INSERT OR UPDATE OR DELETE ON orders
FOR EACH ROW
EXECUTE FUNCTION audit_order_change();

-- ============================================
-- PART 4: THE CASCADE TRIGGER
-- ============================================

CREATE OR REPLACE FUNCTION update_order_total()
RETURNS TRIGGER AS $$
BEGIN
    UPDATE orders
    SET total = (
        SELECT COALESCE(SUM(quantity * price), 0)
        FROM order_items oi
        JOIN products p ON p.id = oi.product_id
        WHERE oi.order_id = COALESCE(NEW.order_id, OLD.order_id)
    )
    WHERE id = COALESCE(NEW.order_id, OLD.order_id);

    RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_update_order_total
AFTER INSERT OR UPDATE OR DELETE ON order_items
FOR EACH ROW
EXECUTE FUNCTION update_order_total();

-- ============================================
-- PART 5: THE INSTEAD OF TRIGGER
-- ============================================

CREATE VIEW user_profiles AS
SELECT u.id, u.email, p.bio, p.avatar_url
FROM users u
LEFT JOIN profiles p ON p.user_id = u.id;

CREATE OR REPLACE FUNCTION insert_user_profile()
RETURNS TRIGGER AS $$
DECLARE
    v_user_id INT;
BEGIN
    INSERT INTO users (email) VALUES (NEW.email)
    RETURNING id INTO v_user_id;

    INSERT INTO profiles (user_id, bio, avatar_url)
    VALUES (v_user_id, NEW.bio, NEW.avatar_url);

    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_insert_user_profile
INSTEAD OF INSERT ON user_profiles
FOR EACH ROW
EXECUTE FUNCTION insert_user_profile();

-- ============================================
-- PART 6: THE SQL SERVER AFTER TRIGGER
-- ============================================

CREATE TRIGGER trg_audit_order
ON orders
AFTER INSERT, UPDATE, DELETE
AS
BEGIN
    SET NOCOUNT ON;
    INSERT INTO order_audit (order_id, action, changed_at)
    SELECT
        COALESCE(i.id, d.id),
        CASE
            WHEN i.id IS NOT NULL AND d.id IS NULL THEN 'INSERT'
            WHEN i.id IS NOT NULL AND d.id IS NOT NULL THEN 'UPDATE'
            WHEN i.id IS NULL AND d.id IS NOT NULL THEN 'DELETE'
        END,
        GETDATE()
    FROM inserted i
    FULL OUTER JOIN deleted d ON i.id = d.id;
END;

-- ============================================
-- PART 7: THE MYSQL BEFORE TRIGGER
-- ============================================

DELIMITER //

CREATE TRIGGER trg_set_updated_at
BEFORE UPDATE ON orders
FOR EACH ROW
BEGIN
    SET NEW.updated_at = NOW();
END //

DELIMITER ;

-- ============================================
-- PART 8: THE ENABLE AND DISABLE
-- ============================================

ALTER TABLE orders DISABLE TRIGGER trg_audit_order;
ALTER TABLE orders ENABLE TRIGGER trg_audit_order;

-- ============================================
-- PART 9: THE DROP
-- ============================================

DROP TRIGGER trg_audit_order ON orders;
DROP FUNCTION audit_order_change();

-- ============================================
-- PART 10: THE COMPLETE PATTERN
-- ============================================

-- 1. Use the BEFORE trigger to validate or modify the row.
-- 2. Use the AFTER trigger to audit or cascade.
-- 3. Use the INSTEAD OF trigger to modify a view.
-- 4. Use the NEW and the OLD to read the values.
-- 5. Use the TG_OP to detect the operation.
-- 6. Use the RAISE EXCEPTION to reject the change.
-- 7. Use the FOR EACH ROW for the row-level trigger.
-- 8. Use the FOR EACH STATEMENT for the statement-level.
-- 9. Disable the trigger during the bulk load.
-- 10. Document the triggers and their purpose.

The ten parts showed the BEFORE trigger, the validation trigger, the audit trigger, the cascade trigger, the INSTEAD OF trigger, the SQL Server AFTER trigger, the MySQL BEFORE trigger, the enable and disable, the drop, and the complete pattern.


Quick Reference

The Trigger Timing

TimingRunsCan modify
BEFOREBefore the row is writtenYes
AFTERAfter the row is writtenNo
INSTEAD OFInstead of the operationYes (on a view)

The Trigger Events

EventThe OLDThe NEW
INSERTnullthe new row
UPDATEthe old rowthe new row
DELETEthe old rownull

The Trigger Variables

VariablePurpose
NEWThe new row
OLDThe old row
TG_OPThe operation
TG_TABLE_NAMEThe table
TG_WHENThe timing

The Trigger Syntax

DatabaseThe BEFORE
PostgreSQLBEFORE UPDATE ... EXECUTE FUNCTION
SQL ServerThe INSTEAD OF alternative
MySQLBEFORE UPDATE ON ... FOR EACH ROW

The Common Uses

Use caseThe timing
The updated_atBEFORE
The validationBEFORE
The auditAFTER
The cascadeAFTER
The view insertINSTEAD OF

Best Practices

✅ Do This:

-- Use the BEFORE trigger for the validation.
CREATE TRIGGER trg_validate BEFORE INSERT ON orders FOR EACH ROW ...;  -- ✅

-- Use the AFTER trigger for the audit.
CREATE TRIGGER trg_audit AFTER INSERT OR UPDATE OR DELETE ON orders ...;  -- ✅

-- Use the NEW and the OLD.
NEW.updated_at := NOW();  -- ✅

-- Use the TG_OP to detect the operation.
IF TG_OP = 'INSERT' THEN ...  -- ✅

-- Use the RAISE EXCEPTION to reject.
RAISE EXCEPTION 'Invalid' USING ERRCODE = '23514';  -- ✅

-- Use the INSTEAD OF for the view.
CREATE TRIGGER trg_insert INSTEAD OF INSERT ON view ...;  -- ✅

-- Disable the trigger during the bulk load.
ALTER TABLE orders DISABLE TRIGGER trg_audit;  -- ✅

-- Document the triggers.
COMMENT ON TRIGGER trg_audit ON orders IS 'The audit log';  -- ✅

❌ Don’t Do This:

-- Don't use the AFTER trigger to modify the row.
CREATE TRIGGER trg AFTER UPDATE ON orders ... NEW.total := 0;  -- ❌ The NEW is read-only

-- Don't use the INSTEAD OF on a table in PostgreSQL.
CREATE TRIGGER trg INSTEAD OF INSERT ON orders ...;  -- ❌ The view only

-- Don't forget the RETURN in the BEFORE trigger.
CREATE FUNCTION f() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at := NOW(); END; $$;  -- ❌ No RETURN

-- Don't create the trigger without the function.
CREATE TRIGGER trg BEFORE UPDATE ON orders ...;  -- ❌ The function is missing

-- Don't use the trigger for the application logic.
-- The trigger is the database rule.  -- ❌

-- Don't forget the FOR EACH ROW.
CREATE TRIGGER trg AFTER UPDATE ON orders ...;  -- ⚠️ The statement-level

Common Pitfalls

PitfallWhy It HappensFix
The trigger does not fireThe wrong timingCheck the BEFORE/AFTER
The row not modifiedNo RETURN NEWAdd the return
The infinite recursionThe trigger updates the same tableUse the condition or the statement-level
The performance slowThe row-level trigger on the bulkDisable during the load
The view not updatableNo INSTEAD OFAdd the trigger
The error not raisedNo RAISEAdd the exception
The trigger disabledThe DISABLE TRIGGEREnable it

Real-World Examples

1. The updated_at

CREATE TRIGGER trg_set_updated_at BEFORE UPDATE ON orders FOR EACH ROW EXECUTE FUNCTION set_updated_at();

2. The Validation

CREATE TRIGGER trg_validate BEFORE INSERT ON orders FOR EACH ROW EXECUTE FUNCTION validate_order();

3. The Audit

CREATE TRIGGER trg_audit AFTER INSERT OR UPDATE OR DELETE ON orders FOR EACH ROW EXECUTE FUNCTION audit_order_change();

4. The Cascade

CREATE TRIGGER trg_update_total AFTER INSERT OR UPDATE OR DELETE ON order_items FOR EACH ROW EXECUTE FUNCTION update_order_total();

5. The INSTEAD OF Insert

CREATE TRIGGER trg_insert INSTEAD OF INSERT ON user_profiles FOR EACH ROW EXECUTE FUNCTION insert_user_profile();

6. The SQL Server AFTER

CREATE TRIGGER trg_audit ON orders AFTER INSERT, UPDATE, DELETE AS ...

7. The MySQL BEFORE

CREATE TRIGGER trg_set BEFORE UPDATE ON orders FOR EACH ROW SET NEW.updated_at = NOW();

8. The Disable

ALTER TABLE orders DISABLE TRIGGER trg_audit;

9. The Drop

DROP TRIGGER trg_audit ON orders;

10. The Complete Pattern

CREATE TRIGGER trg BEFORE UPDATE ON orders FOR EACH ROW EXECUTE FUNCTION f();

Visual

The Trigger Timing

┌────────────────────────────────────────────────────────────┐
│  THE TRIGGER TIMING                                        │
│                                                            │
│  INSERT/UPDATE/DELETE                                      │
│    │                                                       │
│    ├── BEFORE trigger                                      │
│    │     → Validate the row                                │
│    │     → Modify the NEW                                  │
│    │     → Raise the exception                             │
│    │                                                       │
│    ├── The row is written                                  │
│    │                                                       │
│    └── AFTER trigger                                       │
│          → Audit the change                                │
│          → Cascade the update                              │
│          → The NEW is read-only                            │
│                                                            │
│  INSTEAD OF (view only)                                    │
│    → The default action is replaced                        │
│    → The trigger performs the underlying work              │
└────────────────────────────────────────────────────────────┘

The OLD and the NEW

┌────────────────────────────────────────────────────────────┐
│  THE OLD AND THE NEW                                       │
│                                                            │
│  INSERT                                                    │
│  ┌──────────────────────────────────────────────────┐     │
│  │  OLD: null                                         │     │
│  │  NEW: the new row                                  │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  UPDATE                                                    │
│  ┌──────────────────────────────────────────────────┐     │
│  │  OLD: the row before the change                    │     │
│  │  NEW: the row after the change                     │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  DELETE                                                    │
│  ┌──────────────────────────────────────────────────┐     │
│  │  OLD: the row being removed                        │     │
│  │  NEW: null                                         │     │
│  └──────────────────────────────────────────────────┘     │
└────────────────────────────────────────────────────────────┘

The Audit Trigger

┌────────────────────────────────────────────────────────────┐
│  THE AUDIT TRIGGER                                         │
│                                                            │
│  UPDATE orders SET total = 100 WHERE id = 42;              │
│    │                                                       │
│    v                                                       │
│  AFTER trigger fires                                       │
│    │                                                       │
│    v                                                       │
│  INSERT INTO order_audit                                   │
│    (order_id, action, old_total, new_total, changed_at)    │
│  VALUES                                                    │
│    (42, 'UPDATE', 50, 100, NOW());                         │
│                                                            │
│  The audit row is the record of the change.                │
└────────────────────────────────────────────────────────────┘

The INSTEAD OF Trigger

┌────────────────────────────────────────────────────────────┐
│  THE INSTEAD OF TRIGGER                                    │
│                                                            │
│  INSERT INTO user_profiles (email, bio)                    │
│  VALUES ('alice@example.com', 'Hello');                    │
│    │                                                       │
│    v                                                       │
│  INSTEAD OF INSERT trigger fires                           │
│    │                                                       │
│    ├── INSERT INTO users (email) VALUES (...);             │
│    │     → The user_id is returned                         │
│    │                                                       │
│    └── INSERT INTO profiles (user_id, bio) VALUES (...);   │
│                                                            │
│  The view insert is split into the two table inserts.      │
└────────────────────────────────────────────────────────────┘

Summary

ItemValue
BEFORERuns before the row is written
AFTERRuns after the row is written
INSTEAD OFReplaces the operation on a view
NEWThe new row
OLDThe old row
TG_OPThe operation
RAISE EXCEPTIONThe rejection
FOR EACH ROWThe row-level trigger
FOR EACH STATEMENTThe statement-level trigger
DISABLE TRIGGERThe temporary off

Key takeaways:

  • The BEFORE trigger runs before the row is written. It can read the OLD and the NEW, it can modify the NEW, and the modification is what the database stores. The validation, the updated_at, and the computed columns are the uses.
  • The AFTER trigger runs after the row is written. The change is visible, and the trigger cannot modify the row. The audit, the cascade, and the notification are the uses.
  • The INSTEAD OF trigger is only on a view. It intercepts the INSERT, the UPDATE, or the DELETE on the view and replaces the default action. The view that joins the tables is the classic case.
  • The NEW and the OLD are the row references. The NEW is the new row, and the OLD is the old row. The INSERT has the NEW only, the DELETE has the OLD only, and the UPDATE has both.
  • The TG_OP is the operation. The INSERT, the UPDATE, and the DELETE are the values. The trigger uses the TG_OP to branch the behavior.
  • The RAISE EXCEPTION rejects the change. The BEFORE trigger raises the exception, and the change is aborted. The error propagates to the application.
  • The FOR EACH ROW is the row-level. The trigger fires once per row. The FOR EACH STATEMENT fires once per statement, and the transition tables hold the rows.
  • The DISABLE TRIGGER is the bulk-load pattern. The trigger is disabled during the bulk insert, and the enable restores it. The pattern avoids the per-row overhead.

Remember: The database triggers are the automatic rules that run when the table changes. The BEFORE trigger validates or modifies the row, the AFTER trigger audits or cascades, and the INSTEAD OF trigger modifies the view. The NEW and the OLD are the row references, and the TG_OP is the operation. The RAISE EXCEPTION rejects the change, and the FOR EACH ROW is the row-level. The result is the database that enforces the rules, records the audit, and keeps the derived data in sync, regardless of the code path.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!