DML Triggers in SQL Server: AFTER vs INSTEAD OF, and the inserted/deleted Tables

Written by

in

DML Triggers in SQL Server: AFTER vs INSTEAD OF, and the inserted/deleted Tables

Chapter 5 asked “where do I enforce a business rule” and pointed to triggers whenever logic needs to reach across rows or tables — something CHECK constraints structurally can’t do. This is that tool, in full. A trigger is code that runs automatically in response to an INSERT/UPDATE/DELETE. Getting the two DML trigger types right — and understanding their special tables — matters a lot in production, because a trigger bug affects every write to the table, silently.

AFTER vs INSTEAD OF(same event, two very different timings)UPDATE Account SET balance=…AFTER triggerrows are alreadychanged by the timethis code runsextra logic (audit, etc)DELETE FROM Account WHERE…INSTEAD OF triggeroriginal DELETE isREPLACED entirely —never happens unlessthe trigger body does itinserteddeletedboth special tables, populated per STATEMENTGotcha: triggers fire ONCE per statement, not once per row —always JOIN to inserted/deleted, never grab one scalar row. 📌

AFTER Trigger: Audit Logging

CREATE TRIGGER trg_Account_AuditBalance
ON dbo.Account
AFTER UPDATE
AS
BEGIN
    SET NOCOUNT ON;
    IF UPDATE(balance)
    BEGIN
        INSERT INTO dbo.AccountAudit (account_id, old_balance, new_balance)
        SELECT i.account_id, d.balance, i.balance
        FROM inserted i
        INNER JOIN deleted d ON d.account_id = i.account_id
        WHERE i.balance <> d.balance;
    END
END;

UPDATE(balance) is a trigger-specific function that returns true only if the balance column was included in the UPDATE’s SET list — a cheap early-exit that avoids doing audit work on updates that never touched the column you actually care about.

The Two Special Tables

inserted New values (INSERT & UPDATE) deleted Old values (UPDATE & DELETE)

On an UPDATE, both are populated simultaneously — exactly how the audit trigger above compares old vs. new balance by joining them together on the primary key. On a plain INSERT, only inserted has rows; on a plain DELETE, only deleted does.

The Bug That Bites in Production

Triggers fire once per statement, operating on the whole batch of affected rows, not once per row. A trigger written assuming only one row was updated (using a scalar variable instead of joining to inserted/deleted) will silently process only one arbitrary row and miss the rest of a multi-row UPDATE — with no error, just quietly incomplete auditing.

-- The exact bug: looks reasonable, is completely wrong for multi-row updates
CREATE TRIGGER trg_Bad_AuditBalance ON dbo.Account AFTER UPDATE AS
BEGIN
    DECLARE @id INT, @newBalance DECIMAL(10,2);
    SELECT @id = account_id, @newBalance = balance FROM inserted; -- only grabs ONE row
    INSERT INTO dbo.AccountAudit (account_id, new_balance) VALUES (@id, @newBalance);
END;

-- Prove the bug: update multiple rows in one statement, check the audit table
UPDATE dbo.Account SET balance = balance * 1.01 WHERE balance > 0; -- affects many rows
SELECT COUNT(*) FROM dbo.AccountAudit; -- only 1 row logged, not one per account updated
Common mistake: Testing a trigger only against single-row UPDATE statements during development, where the scalar-variable bug above produces correct-looking results by coincidence. It fails silently the first time a real batch UPDATE or bulk import touches multiple rows at once — always test triggers against multi-row operations before trusting them.

INSTEAD OF: Replacing the Operation Entirely

CREATE TRIGGER trg_Account_PreventDeleteIfFunded
ON dbo.Account
INSTEAD OF DELETE
AS
BEGIN
    SET NOCOUNT ON;
    IF EXISTS (SELECT 1 FROM deleted WHERE balance > 0)
    BEGIN
        RAISERROR('Cannot delete an account with a positive balance.', 16, 1);
        RETURN;
    END
    DELETE FROM dbo.Account WHERE account_id IN (SELECT account_id FROM deleted);
END;

INSTEAD OF triggers replace the operation entirely — the original INSERT/UPDATE/DELETE never happens unless the trigger body performs it itself. This is the exact mechanism that makes a JOIN-based view (Chapter 6) updatable despite SQL Server’s own restrictions: an INSTEAD OF trigger on the view can manually split the write across the correct base tables, however that logic needs to work.

Practice tip: Rebuild both example triggers, then deliberately run a multi-row UPDATE against Account and confirm the AFTER trigger logs every changed row correctly. Then try deleting a funded account and confirm the INSTEAD OF trigger blocks it with the custom error message.

Enjoyed this?

Subscribe to get every new SQL Server lesson as soon as it’s published, and share it with a developer who’d find it useful.

📡 Subscribe via RSS  | 
Share on X  | 
Share on LinkedIn  | 
Share on Facebook

Want the full structured course with quizzes, projects, and 10+ exercises per chapter? Check out SQL Server for Developers & DBAs, coming soon on this site.