INSTEAD OF Triggers — Intercepting INSERT, UPDATE and DELETE

📋 Table of Contents
  1. INSTEAD OF Triggers
  2. Common Mistakes

An INSTEAD OF trigger replaces the DML operation that fired it — when an INSERT, UPDATE, or DELETE is attempted, the trigger runs instead. This enables writing to non-updatable views (routing the write to correct underlying tables), implementing soft deletes at the database level, and intercepting writes to enforce complex business rules without changing application code. The inserted and deleted virtual tables inside the trigger contain the rows that would have been affected.

INSTEAD OF Triggers

-- ── INSTEAD OF INSERT on a view — route writes to correct tables ──────────
-- The view vw_PublishedPostSummary is normally read-only.
-- This trigger handles INSERT attempts against it.

CREATE OR ALTER TRIGGER trg_vw_PublishedPost_Insert
ON dbo.vw_PublishedPostSummary
INSTEAD OF INSERT
AS
BEGIN
    SET NOCOUNT ON;

    -- The inserted virtual table contains the rows the caller tried to insert
    INSERT INTO dbo.Posts (AuthorId, Title, Slug, Body, Status, IsPublished, PublishedAt)
    SELECT
        i.AuthorId,    -- caller must supply these
        i.Title,
        i.Slug,
        i.Body,
        'published',   -- enforce status
        1,
        SYSUTCDATETIME()
    FROM inserted i;   -- 'inserted' = virtual table of rows being inserted
END;
GO

-- ── INSTEAD OF DELETE — soft delete instead of physical removal ────────────
-- When a Post is deleted, mark it as archived rather than removing the row

CREATE OR ALTER TRIGGER trg_Posts_SoftDelete
ON dbo.Posts
INSTEAD OF DELETE
AS
BEGIN
    SET NOCOUNT ON;

    UPDATE dbo.Posts
    SET    Status    = 'archived',
           IsPublished = 0,
           UpdatedAt = SYSUTCDATETIME()
    FROM   dbo.Posts p
    JOIN   deleted   d ON d.Id = p.Id;  -- 'deleted' = rows the caller tried to delete

    -- Also hide associated comments
    UPDATE dbo.Comments
    SET    IsApproved = 0
    WHERE  PostId IN (SELECT Id FROM deleted);
END;
GO

-- ── Verify the trigger fires on DELETE ───────────────────────────────────
DELETE FROM dbo.Posts WHERE Id = 42;
-- Row is NOT deleted — it is archived (Status = 'archived', IsPublished = 0)
SELECT Id, Title, Status, IsPublished FROM dbo.Posts WHERE Id = 42;

-- ── Trigger metadata ──────────────────────────────────────────────────────
SELECT t.name, t.type_desc, o.name AS TableOrView
FROM   sys.triggers t
JOIN   sys.objects  o ON o.object_id = t.parent_id
WHERE  o.name IN ('Posts', 'vw_PublishedPostSummary');

-- ── Disable and enable triggers ───────────────────────────────────────────
DISABLE TRIGGER trg_Posts_SoftDelete ON dbo.Posts;  -- e.g. during bulk import
-- ... bulk operations ...
ENABLE  TRIGGER trg_Posts_SoftDelete ON dbo.Posts;
Note: Inside a trigger, inserted contains the new rows (for INSERT and UPDATE) and deleted contains the old rows (for DELETE and UPDATE). For an UPDATE trigger, inserted has the new values and deleted has the old values — allowing you to compare before and after. Triggers handle batches — if 10 rows are deleted at once, deleted contains all 10 rows. Never write triggers that assume one row at a time (using scalar variables like DECLARE @Id INT = (SELECT Id FROM deleted)) — this breaks silently when more than one row is affected.
Tip: Disable triggers during bulk import or migration scripts with DISABLE TRIGGER triggerName ON tableName and re-enable with ENABLE TRIGGER after the operation. Audit and soft-delete triggers that fire for every row add significant overhead to bulk operations — importing 10,000 posts with an audit trigger firing per row can be 10x slower than without. After the bulk operation, consider running a single audit entry for the entire batch rather than per-row entries.
Warning: INSTEAD OF triggers on views that try to do complex multi-table inserts can become unmaintainable as the view grows. If the view logic changes, the trigger must be updated accordingly. For complex write operations, prefer stored procedures (which have explicit logic) over triggers (which are hidden side effects). Triggers should be used when you genuinely need to intercept database-level operations invisibly — soft delete is a legitimate use case; complex business logic is not.

Common Mistakes

Mistake 1 — Trigger assumes single-row operation (breaks on multi-row DML)

❌ Wrong — DECLARE @Id INT = (SELECT Id FROM deleted); deleting 3 rows causes subquery to return multiple values; error.

✅ Correct — always JOIN against the inserted/deleted virtual tables as a set: JOIN deleted d ON d.Id = p.Id.

Mistake 2 — Trigger on high-volume table without measuring overhead

❌ Wrong — audit trigger on a PostViewLog table receiving 10,000 inserts/minute; trigger overhead causes blocking.

✅ Correct — benchmark trigger overhead; for high-volume tables, use application-level audit or a separate async audit queue.

🧠 Test Yourself

An INSTEAD OF DELETE trigger is on the Posts table. The application calls EF Core’s _db.Posts.Remove(post) and SaveChangesAsync(). What physically happens to the row?