Track contentsDay 19 of 30

Day 19: Triggers

Intermediate2 hrs 30 minWorks in: SQL Server 2025, SSMS 22
On this page
  1. A. Lesson overview
  2. B. Theory explained clearly
  3. C. Dataset and starting state
  4. D. Worked examples
  5. E. Guided practical lab — A complete customer audit
  6. F. Independent practice
  7. G. Solutions and explanations
  8. H. Knowledge check
  9. I. Troubleshooting and cleanup
  10. J. Completion checklist, recap and references

By the end of this lesson you can

  • Create AFTER and INSTEAD OF DML triggers and explain when each fires.
  • Use the inserted and deleted pseudo-tables to see the before and after state of every changed row.
  • Write triggers that are correct for 0, 1 or many rows, and recognise the single-row (scalar variable) bug.
  • Reject invalid changes from a trigger with THROW, and explain what is rolled back.
  • Explain nested and recursive firing, UPDATE(column), and how to disable a trigger temporarily.
  • Decide when a constraint, a temporal table or a procedure is a better choice than a trigger.

A. Lesson overview

Week 3 — Data modification, programmability and error handling
Level Intermediate → Advanced
Estimated time 2 h 30 min essential + 30 min optional stretch
Prerequisites Day 15 (OUTPUT, joins in UPDATE), Day 16 (transactions), Day 17 (procedures), Day 18 (functions)
Syllabus topic Triggers — AFTER and INSTEAD OF triggers, inserted/deleted tables, auditing, multi-row handling

Activity breakdown

Activity Time
Part 1 — What a trigger is and when it fires 15 min
Part 2 — inserted and deleted; the multi-row rule 20 min
Part 3 — AFTER vs INSTEAD OF; errors and transactions 20 min
Part 4 — Nesting, recursion, UPDATE(), and when not to use a trigger 15 min
Worked examples 30 min
Guided lab 25 min
Independent practice 20 min
Knowledge check and wrap-up 5 min
Stretch challenge (optional) 30 min

Business scenario. Auditors want a record of every list-price change and every customer change — however the change is made, including ad hoc fixes. Sales want “deleted” customers kept for history. Operations want stock to go down automatically when order lines are recorded. Each request is a candidate for a trigger; each also has a way to go wrong when many rows change at once.


B. Theory explained clearly

Part 1 — What is a trigger?

A DML trigger is a special procedure attached to a table (or view) that runs automatically when an INSERT, UPDATE or DELETE statement affects that table. You never call it directly.

CREATE OR ALTER TRIGGER Lab.trg_Product_PriceAudit
ON Lab.Product
AFTER UPDATE                        -- or INSERT, DELETE, or a list: AFTER INSERT, UPDATE, DELETE
AS
BEGIN
    SET NOCOUNT ON;                 -- stop extra "(n rows affected)" messages confusing client code
    -- logic here, using the inserted and deleted tables
END;

The single most important fact: a trigger fires once per statement, not once per row. An UPDATE that changes 500 rows fires the trigger once, with 500 rows to process. An UPDATE that changes 0 rows still fires it, with no rows.

There are also DDL triggers (fire on CREATE, ALTER, DROP and similar — today’s stretch) and logon triggers (server level, not covered).

Part 2 — inserted and deleted

Inside a DML trigger, two read-only tables with the same columns as the parent table are available:

Statement inserted contains deleted contains
INSERT the new rows nothing
UPDATE the rows after the change the same rows before the change
DELETE nothing the removed rows

To compare before and after for an update, join them on the primary key:

SELECT i.ProductID, d.ListPrice AS OldPrice, i.ListPrice AS NewPrice
FROM   inserted AS i
JOIN   deleted  AS d ON d.ProductID = i.ProductID;

(If a statement changes the primary key value itself, this join cannot match old to new. Treat primary keys as unchangeable — another reason for surrogate keys.)

The multi-row rule

This trigger looks reasonable and is wrong:

DECLARE @ProductID int, @NewPrice decimal(10,2);
SELECT  @ProductID = ProductID, @NewPrice = ListPrice FROM inserted;   -- keeps ONE row's values

When several rows change, the variable holds the values from just one of them (which one is not defined), and every other row is silently ignored. Single-row testing will never reveal it. Always write trigger logic as set-based statements over inserted/deleted, never by copying them into scalar variables.

Part 3 — AFTER vs INSTEAD OF

AFTER trigger INSTEAD OF trigger
When it runs After the statement has changed the table and constraints have been checked Instead of the statement — the table is not changed unless the trigger does it
Allowed on Tables Tables and views (one per action)
Typical use Auditing, maintaining derived data, rejecting invalid changes Soft deletes, making a complex view updatable, redirecting or cleaning data

Errors and transactions. A trigger runs inside the transaction of the statement that fired it. If the trigger raises an error with THROW (or a statement inside it fails), the batch is aborted and the transaction is rolled back — the original change and everything the trigger did are undone. That is how a trigger rejects invalid data. Day 20 covers how callers catch such errors.

IF EXISTS (SELECT 1 FROM inserted WHERE …invalid…)
    THROW 50010, N'Explain the rule that was broken.', 1;

Constraints run before AFTER triggers. If a CHECK or foreign key fails, the AFTER trigger never runs.

Part 4 — Behaviours to know

Topic What happens
Nested triggers A trigger’s own INSERT/UPDATE/DELETE can fire triggers on other tables (or other triggers on the same table), up to 32 levels. Controlled by the server option nested triggers (on by default)
Recursive triggers A trigger that updates its own table does not fire itself again while the database option RECURSIVE_TRIGGERS is OFF (the default). Other triggers on that table do fire
UPDATE(column) True if the column appeared in the SET list (or the statement is an INSERT) — even if the value did not change. Use it to skip work cheaply, then compare inserted and deleted for real changes
IS DISTINCT FROM (SQL Server 2022+) compares values treating two NULLs as equal: i.Email IS DISTINCT FROM d.Email is the NULL-safe “changed” test
Several triggers for one action Allowed; the order is undefined except for first/last set with sp_settriggerorder. Prefer one trigger per table and action
Disable / enable DISABLE TRIGGER Lab.trg_X ON Lab.Product; … ENABLE TRIGGER Lab.trg_X ON Lab.Product; — for maintenance only, and audit who does it
TRUNCATE TABLE Does not fire DELETE triggers
Bulk loads BULK INSERT and bcp do not fire triggers unless the FIRE_TRIGGERS option is used

When not to use a trigger

Triggers are invisible to whoever writes the UPDATE, add work to every change, and are easy to get wrong. Prefer:

Need Better first choice
Value rules on one row CHECK constraint
Row must exist in another table Foreign key
Full row history System-versioned temporal table (Day 12)
Default values DEFAULT constraint
A multi-step business operation Stored procedure (Day 17)

Use a trigger when the rule must hold however the data is changed and none of the above can express it — for example, auditing selected columns, rules that look at another table, or soft deletes.


C. Dataset and starting state

Dataset Shared synthetic dataset; all changes go to Lab copies
Database RetailLab
Read from Inventory.Product, Inventory.Stock, Sales.Customer, Staging.CustomerImport
Changed Lab.Product, Lab.Customer, Lab.OrderLine, Lab.ProductStock, Lab.ProductPriceAudit, Lab.CustomerAudit
Permissions ALTER on the Lab tables (needed to create DML triggers); the stretch needs ALTER ANY DATABASE DDL TRIGGER (the database owner has it)
Objects created The tables above (by the script); triggers trg_Product_PriceAudit, trg_OrderLine_CheckProduct, trg_Customer_SoftDelete, trg_Customer_Audit, trg_Product_Touch, trg_Product_PriceGuard, trg_OrderLine_Stock; the stretch’s database trigger trg_DDL_SchemaLog and table Lab.SchemaChangeLog

Setup. Open and run scripts/lessons/day19_trigger_lab.sql (download it). It is destructive within Lab: it drops and recreates the six tables above (and therefore any triggers on them). Re-run it whenever you want to start again.

Table Contents at start
Lab.Product 30 products: ProductID, ProductCode, ProductName, CategoryID, ListPrice, StandardCost, DiscontinuedDate
Lab.Customer 40 customers (37 active) — same shape as Sales.Customer
Lab.OrderLine Empty: OrderLineID (IDENTITY), OrderID, ProductID, Quantity (> 0), UnitPrice
Lab.ProductStock 29 rows, one per stocked product, QuantityOnHand summed over warehouses; CHECK (QuantityOnHand >= 0). TNT-004 (discontinued) has no stock
Lab.ProductPriceAudit Empty: AuditID, ProductID, OldListPrice, NewListPrice, ChangedAt, ChangedBy
Lab.CustomerAudit Empty: AuditID, AuditAction (I/U/D), CustomerID, old/new Email, Segment, IsActive, AuditedAt, AuditedBy

Validation. The script ends with this query. Expected: 30, 40, 37, 0, 29, 1142, 0, 0.

SELECT  (SELECT COUNT(*)            FROM Lab.Product)            AS Products,
        (SELECT COUNT(*)            FROM Lab.Customer)           AS Customers,
        (SELECT SUM(CAST(IsActive AS int)) FROM Lab.Customer)    AS ActiveCustomers,
        (SELECT COUNT(*)            FROM Lab.OrderLine)          AS OrderLines,
        (SELECT COUNT(*)            FROM Lab.ProductStock)       AS StockProducts,
        (SELECT SUM(QuantityOnHand) FROM Lab.ProductStock)       AS TotalUnits,
        (SELECT COUNT(*) FROM Lab.ProductPriceAudit) + (SELECT COUNT(*) FROM Lab.CustomerAudit) AS AuditRows,
        (SELECT COUNT(*) FROM sys.triggers AS t
         JOIN sys.tables AS tb ON tb.object_id = t.parent_id
         WHERE tb.schema_id = SCHEMA_ID('Lab'))                  AS LabTableTriggers;

D. Worked examples

Worked example 1 — The single-row bug

CREATE OR ALTER TRIGGER Lab.trg_Product_PriceAudit
ON Lab.Product
AFTER UPDATE
AS
BEGIN
    SET NOCOUNT ON;
    -- WRONG: assumes exactly one row changed
    DECLARE @ProductID int, @Old decimal(10,2), @New decimal(10,2);
    SELECT @ProductID = ProductID, @New = ListPrice FROM inserted;
    SELECT @Old = ListPrice FROM deleted WHERE ProductID = @ProductID;

    IF @Old <> @New
        INSERT INTO Lab.ProductPriceAudit (ProductID, OldListPrice, NewListPrice)
        VALUES (@ProductID, @Old, @New);
END;
GO

UPDATE Lab.Product
SET    ListPrice = ListPrice + 1.00
WHERE  ProductCode IN ('TNT-001', 'SLP-003', 'COK-003');

SELECT COUNT(*) AS AuditRows FROM Lab.ProductPriceAudit;

Expected result: (3 rows affected) from the UPDATE, but AuditRows = 1. Two price changes have no audit record. Which product was recorded is not defined — that unpredictability is part of the bug.

Worked example 2 — The set-based fix (and disabling a trigger)

CREATE OR ALTER TRIGGER Lab.trg_Product_PriceAudit
ON Lab.Product
AFTER UPDATE
AS
BEGIN
    SET NOCOUNT ON;
    IF NOT UPDATE(ListPrice) RETURN;          -- ListPrice not in the SET list: nothing to audit

    INSERT INTO Lab.ProductPriceAudit (ProductID, OldListPrice, NewListPrice)
    SELECT  i.ProductID, d.ListPrice, i.ListPrice
    FROM    inserted AS i
    JOIN    deleted  AS d ON d.ProductID = i.ProductID
    WHERE   i.ListPrice <> d.ListPrice;       -- only real changes (ListPrice is NOT NULL)
END;
GO

-- Put the prices back WITHOUT auditing the correction, then clear the audit
DISABLE TRIGGER Lab.trg_Product_PriceAudit ON Lab.Product;
UPDATE lp SET ListPrice = p.ListPrice
FROM   Lab.Product AS lp JOIN Inventory.Product AS p ON p.ProductID = lp.ProductID
WHERE  lp.ListPrice <> p.ListPrice;
ENABLE TRIGGER Lab.trg_Product_PriceAudit ON Lab.Product;
TRUNCATE TABLE Lab.ProductPriceAudit;

-- Test 1: three real changes
UPDATE Lab.Product SET ListPrice = ListPrice + 1.00 WHERE ProductCode IN ('TNT-001', 'SLP-003', 'COK-003');
-- Test 2: four rows "updated" but no price changes
UPDATE Lab.Product SET ListPrice = ListPrice WHERE CategoryID = 2;
-- Test 3: zero rows
UPDATE Lab.Product SET ListPrice = 1.00 WHERE 1 = 0;

SELECT a.ProductID, p.ProductCode, a.OldListPrice, a.NewListPrice
FROM   Lab.ProductPriceAudit AS a JOIN Lab.Product AS p ON p.ProductID = a.ProductID
ORDER BY a.ProductID;

Expected result: 3 audit rows — 1 TNT-001 189.00 190.00, 6 SLP-003 59.00 60.00, 9 COK-003 6.50 7.50. Test 2 (4 rows affected) and test 3 (0 rows affected) fired the trigger but added nothing. Leave the three +1.00 prices in place; later steps don’t depend on them.

Worked example 3 — Rejecting invalid rows with THROW

A CHECK constraint can’t look at another table, so “you can’t order a discontinued product” needs a trigger.

CREATE OR ALTER TRIGGER Lab.trg_OrderLine_CheckProduct
ON Lab.OrderLine
AFTER INSERT, UPDATE
AS
BEGIN
    SET NOCOUNT ON;
    IF EXISTS (SELECT 1
               FROM   inserted AS i
               LEFT JOIN Lab.Product AS p ON p.ProductID = i.ProductID
               WHERE  p.ProductID IS NULL OR p.DiscontinuedDate IS NOT NULL)
        THROW 50010, N'Order lines must reference an existing product that is not discontinued.', 1;
END;
GO

INSERT INTO Lab.OrderLine (OrderID, ProductID, Quantity, UnitPrice)
VALUES (9001, 1, 2, 189.00),
       (9001, 30, 1, 599.00);      -- product 30 = TNT-004, discontinued

SELECT COUNT(*) AS OrderLines FROM Lab.OrderLine;

Expected result: Msg 50010 … Order lines must reference an existing product that is not discontinued. Then OrderLines = 0 — the valid first row was rolled back too, because the whole statement is one transaction. (The identity values used by the failed insert are not reused, so later OrderLineIDs may start above 1.)

The error ends the batch, so run the SELECT again on its own to see the count.

Worked example 4 — INSTEAD OF DELETE for soft deletes

CREATE OR ALTER TRIGGER Lab.trg_Customer_SoftDelete
ON Lab.Customer
INSTEAD OF DELETE
AS
BEGIN
    SET NOCOUNT ON;
    UPDATE c
    SET    IsActive = 0
    FROM   Lab.Customer AS c
    JOIN   deleted      AS d ON d.CustomerID = c.CustomerID
    WHERE  c.IsActive = 1;
END;
GO

DELETE FROM Lab.Customer WHERE CustomerID IN (38, 39, 40);

SELECT COUNT(*) AS Customers, SUM(CAST(IsActive AS int)) AS ActiveCustomers FROM Lab.Customer;
SELECT CustomerID, FirstName, LastName, IsActive FROM Lab.Customer WHERE CustomerID >= 38 ORDER BY CustomerID;

Expected result: Customers 40, ActiveCustomers 34; Freya Lindqvist, Arjun Mehta and Hugo Bernard are still there with IsActive 0. The DELETE never removed anything — deleted told the trigger which rows the statement targeted.


E. Guided practical lab — A complete customer audit

Build one trigger that audits inserts, updates and deletes on Lab.Customer, test it with multi-row statements, and see how it interacts with the soft-delete trigger.

Step 1 — Create the audit trigger

  1. Do: One trigger for all three actions. A FULL OUTER JOIN of inserted and deleted tells you the action per row.
  2. Run:
    USE RetailLab;
    GO
    
    CREATE OR ALTER TRIGGER Lab.trg_Customer_Audit
    ON Lab.Customer
    AFTER INSERT, UPDATE, DELETE
    AS
    BEGIN
        SET NOCOUNT ON;
    
        INSERT INTO Lab.CustomerAudit
              (AuditAction, CustomerID, OldEmail, NewEmail, OldSegment, NewSegment, OldIsActive, NewIsActive)
        SELECT  CASE WHEN d.CustomerID IS NULL THEN 'I'
                     WHEN i.CustomerID IS NULL THEN 'D'
                     ELSE 'U' END,
                COALESCE(i.CustomerID, d.CustomerID),
                d.Email, i.Email, d.CustomerSegment, i.CustomerSegment, d.IsActive, i.IsActive
        FROM    inserted AS i
        FULL OUTER JOIN deleted AS d ON d.CustomerID = i.CustomerID
        WHERE   d.CustomerID IS NULL                                  -- insert
           OR   i.CustomerID IS NULL                                  -- delete
           OR   i.Email           IS DISTINCT FROM d.Email            -- update of an audited column
           OR   i.CustomerSegment IS DISTINCT FROM d.CustomerSegment
           OR   i.IsActive        IS DISTINCT FROM d.IsActive;
    END;
    GO
  3. Observe: Commands completed successfully.
  4. Why it matters: One set-based statement handles any number of rows and all three actions. IS DISTINCT FROM treats NULL → value and value → NULL as changes (Email allows NULL), which <> would miss.
  5. Confirm: SELECT name, is_instead_of_trigger FROM sys.triggers WHERE parent_id = OBJECT_ID('Lab.Customer'); → trg_Customer_Audit 0 and trg_Customer_SoftDelete 1.

Step 2 — Multi-row insert

  1. Do: Add two customers from the import in one statement.
  2. Run:
    INSERT INTO Lab.Customer
           (CustomerID, FirstName, LastName, Email, Phone, City, Country, CustomerSegment, DateJoined, IsActive)
    VALUES (41, N'Daisy', N'Holt',   N'daisy.holt@example.com',  '07700 900301', N'Bath',   N'United Kingdom', 'Consumer', '2026-01-02', 1),
           (42, N'Kofi',  N'Mensah', N'kofi.mensah@example.com', NULL,           N'London', N'United Kingdom', 'Consumer', '2026-01-02', 1);
    
    SELECT AuditAction, CustomerID, NewEmail, NewSegment, NewIsActive
    FROM   Lab.CustomerAudit ORDER BY AuditID;
  3. Observe: two I rows, for 41 and 42.
  4. Why it matters: Proves the trigger is not limited to one row.
  5. Confirm: the old-value columns are NULL for inserts.

Step 3 — Multi-row update, and an update that changes nothing

  1. Do: Upgrade London business customers to VIP; then run an update that touches rows without changing audited columns.
  2. Run:
    UPDATE Lab.Customer SET CustomerSegment = 'VIP' WHERE CustomerSegment = 'Business' AND City = N'London';
    UPDATE Lab.Customer SET City = City            WHERE Country = N'Ireland';
    
    SELECT AuditAction, CustomerID, OldSegment, NewSegment
    FROM   Lab.CustomerAudit WHERE AuditAction = 'U' ORDER BY CustomerID;
  3. Observe: (2 rows affected) then (4 rows affected); the audit shows 2 U rows: customers 13 and 39, Business → VIP. The Ireland update produced nothing.
  4. Why it matters: Customer 39 is inactive (soft-deleted in worked example 4) but was still updated — the WHERE didn’t filter on IsActive. Audits capture what actually happened, not what was intended.
  5. Confirm: SELECT COUNT(*) FROM Lab.CustomerAudit; → 4.

Step 4 — Delete, and watch the triggers interact

  1. Do: Delete the two new customers.
  2. Run:
    DELETE FROM Lab.Customer WHERE CustomerID IN (41, 42);
    
    SELECT AuditAction, CustomerID, OldIsActive, NewIsActive
    FROM   Lab.CustomerAudit WHERE CustomerID IN (41, 42) ORDER BY AuditID;
  3. Observe: For each customer: the I row from step 2, then a U row with IsActive 1 → 0. No D rows.
  4. Why it matters: The INSTEAD OF DELETE trigger replaced the delete with an UPDATE, and that UPDATE fired the AFTER audit trigger (a nested trigger). The audit correctly records what really happened: a soft delete.
  5. Confirm: SELECT COUNT(*) FROM Lab.Customer; → 42.

Step 5 — Summarise

  1. Do: Check the totals.
  2. Run:
    SELECT AuditAction, COUNT(*) AS AuditRows FROM Lab.CustomerAudit GROUP BY AuditAction ORDER BY AuditAction;
    SELECT COUNT(*) AS Customers, SUM(CAST(IsActive AS int)) AS ActiveCustomers FROM Lab.Customer;
  3. Observe: I 2, U 4; 42 customers, 34 active.
  4. Why it matters: Totals you can predict before running are the best test of trigger logic.
  5. Confirm: the numbers match.

F. Independent practice

Tasks 1 and 2 add triggers to Lab.Product, where trg_Product_PriceAudit already exists; task 3 works on Lab.OrderLine.

Task 1 — Maintain a LastModified column

Add LastModified datetime2(0) NULL to Lab.Product. Create Lab.trg_Product_Touch (AFTER UPDATE) that sets it to the current date and time for every updated row. Make sure the trigger cannot loop, even if RECURSIVE_TRIGGERS were turned on. Test with one statement that raises the prices of TNT-002 and TNT-003 by 10.00.

Success criteria: exactly 2 products have a LastModified value, 28 are NULL; Lab.ProductPriceAudit gains exactly 2 rows from your test.

Task 2 — Block large price rises

Create Lab.trg_Product_PriceGuard (AFTER UPDATE) that rejects the whole statement with error 50020 if any row’s new list price is more than 20% above its old price. Test with ListPrice * 1.15 and then ListPrice * 1.25 for the Navigation category (CategoryID = 10).

Success criteria: the first update succeeds (4 rows); the second fails with error 50020; afterwards NAV-001 28.74, NAV-002 343.85, NAV-003 17.24, NAV-004 401.35. The audit contains 4 rows for the Navigation products from the first update and none from the second.

Task 3 — Decrease stock as order lines arrive (challenging)

Create Lab.trg_OrderLine_Stock (AFTER INSERT) that reduces Lab.ProductStock.QuantityOnHand by the quantity ordered. Test with one statement inserting four lines: order 9001 — product 1 × 2, product 6 × 1; order 9002 — product 1 × 3, product 24 × 4. Then try one more line: order 9003, product 6 × 5.

Success criteria: after the first insert: product 1 = 64, product 6 = 1, product 24 = 52, total units 1132, 4 order lines. The second insert fails with a CHECK constraint error (product 6 would go negative); stock and order lines are unchanged.

Hint

Product 1 appears twice in one statement. A plain UPDATE … FROM Lab.ProductStock JOIN inserted would use only one of its two matching rows. Aggregate inserted by ProductID first.

Stretch challenge — A database-level DDL audit

Create Lab.SchemaChangeLog (LogID int IDENTITY PRIMARY KEY, EventTime datetime2(0), EventType nvarchar(100), ObjectName nvarchar(256), LoginName sysname, CommandText nvarchar(max)) and a database trigger trg_DDL_SchemaLog for DDL_TABLE_EVENTS, DDL_VIEW_EVENTS, DDL_PROCEDURE_EVENTS and DDL_FUNCTION_EVENTS that records each event from EVENTDATA(). Test by creating Lab.DdlTest, adding a column, and dropping it. Drop the DDL trigger afterwards — otherwise it will log everything you do for the rest of the course.

Success criteria: 3 log rows: CREATE_TABLE, ALTER_TABLE, DROP_TABLE, each with ObjectName Lab.DdlTest.


G. Solutions and explanations

Show solution 1
ALTER TABLE Lab.Product ADD LastModified datetime2(0) NULL;
GO

CREATE OR ALTER TRIGGER Lab.trg_Product_Touch
ON Lab.Product
AFTER UPDATE
AS
BEGIN
    SET NOCOUNT ON;
    IF UPDATE(LastModified) RETURN;     -- the statement (or this trigger's own UPDATE) set it already

    UPDATE p
    SET    LastModified = SYSDATETIME()
    FROM   Lab.Product AS p
    JOIN   inserted    AS i ON i.ProductID = p.ProductID;
END;
GO

UPDATE Lab.Product SET ListPrice = ListPrice + 10.00 WHERE ProductCode IN ('TNT-002', 'TNT-003');

SELECT SUM(CASE WHEN LastModified IS NOT NULL THEN 1 ELSE 0 END) AS Touched,
       SUM(CASE WHEN LastModified IS NULL     THEN 1 ELSE 0 END) AS Untouched
FROM   Lab.Product;

SELECT a.ProductID, a.OldListPrice, a.NewListPrice
FROM   Lab.ProductPriceAudit AS a WHERE a.ProductID IN (2, 3) ORDER BY a.ProductID;

Result: Touched 2, Untouched 28; audit rows 2 349.00 359.00 and 3 249.00 259.00.

Reasoning. The trigger’s own UPDATE fires the other AFTER UPDATE trigger (the price audit), which finds no price change and logs nothing. With RECURSIVE_TRIGGERS on, it would also fire trg_Product_Touch again — the IF UPDATE(LastModified) RETURN guard stops that, because the nested statement’s SET list contains LastModified. A DEFAULT handles inserts; a trigger is needed only for updates. A temporal table (Day 12) is the alternative when you need full history, not just “last changed”.

Show solution 2
CREATE OR ALTER TRIGGER Lab.trg_Product_PriceGuard
ON Lab.Product
AFTER UPDATE
AS
BEGIN
    SET NOCOUNT ON;
    IF NOT UPDATE(ListPrice) RETURN;

    IF EXISTS (SELECT 1
               FROM   inserted AS i
               JOIN   deleted  AS d ON d.ProductID = i.ProductID
               WHERE  i.ListPrice > d.ListPrice * 1.20)
        THROW 50020, N'A list price cannot rise by more than 20% in one change.', 1;
END;
GO

UPDATE Lab.Product SET ListPrice = ListPrice * 1.15 WHERE CategoryID = 10;   -- succeeds
GO
UPDATE Lab.Product SET ListPrice = ListPrice * 1.25 WHERE CategoryID = 10;   -- error 50020
GO
SELECT ProductCode, ListPrice FROM Lab.Product WHERE CategoryID = 10 ORDER BY ProductCode;
SELECT COUNT(*) AS NavAuditRows FROM Lab.ProductPriceAudit WHERE ProductID IN (21, 22, 23, 29);

Result: first update (4 rows affected); second fails with Msg 50020; prices 28.74, 343.85, 17.24, 401.35; NavAuditRows 4.

Reasoning. ListPrice * 1.15 on a decimal(10,2) column is rounded to 2 decimal places when stored (24.99 × 1.15 = 28.7385 → 28.74). The failed statement was rolled back entirely — including the audit rows the audit trigger had written for it, because all triggers share the statement’s transaction. The GOs put each statement in its own batch so the error doesn’t stop the checks.

Show solution 3
CREATE OR ALTER TRIGGER Lab.trg_OrderLine_Stock
ON Lab.OrderLine
AFTER INSERT
AS
BEGIN
    SET NOCOUNT ON;

    UPDATE s
    SET    QuantityOnHand = s.QuantityOnHand - x.Qty
    FROM   Lab.ProductStock AS s
    JOIN  (SELECT ProductID, SUM(Quantity) AS Qty
           FROM   inserted
           GROUP BY ProductID) AS x ON x.ProductID = s.ProductID;
END;
GO

INSERT INTO Lab.OrderLine (OrderID, ProductID, Quantity, UnitPrice)
VALUES (9001,  1, 2, 189.00),
       (9001,  6, 1,  59.00),
       (9002,  1, 3, 189.00),
       (9002, 24, 4,  39.00);
GO
INSERT INTO Lab.OrderLine (OrderID, ProductID, Quantity, UnitPrice)
VALUES (9003, 6, 5, 59.00);                  -- fails: stock would be -4
GO
SELECT ProductID, QuantityOnHand FROM Lab.ProductStock WHERE ProductID IN (1, 6, 24) ORDER BY ProductID;
SELECT SUM(QuantityOnHand) AS TotalUnits, (SELECT COUNT(*) FROM Lab.OrderLine) AS OrderLines FROM Lab.ProductStock;

Result: 1 64, 6 1, 24 52; TotalUnits 1132, OrderLines 4. The second insert fails with The UPDATE statement conflicted with the CHECK constraint “CK_LabProductStock_NonNegative” — raised inside the trigger, so the insert is rolled back too.

Reasoning. Without the GROUP BY, JOIN inserted gives product 1 two matching source rows; an UPDATE … FROM with several matches applies only one of them (which one is undefined), so product 1 would end at 67 or 66 instead of 64 — no error, just wrong stock. The CHECK constraint on Lab.ProductStock does the “not enough stock” validation for free. The trg_OrderLine_CheckProduct trigger from worked example 3 also fires for these inserts; product 30 is the only one it rejects. In a real system, also handle UPDATE and DELETE of order lines — or put the whole operation in a procedure.

Show the stretch solution
DROP TABLE IF EXISTS Lab.SchemaChangeLog;
CREATE TABLE Lab.SchemaChangeLog
(
    LogID       int IDENTITY(1,1) NOT NULL PRIMARY KEY,
    EventTime   datetime2(0)  NOT NULL DEFAULT (SYSDATETIME()),
    EventType   nvarchar(100) NOT NULL,
    ObjectName  nvarchar(256) NULL,
    LoginName   sysname       NOT NULL,
    CommandText nvarchar(max) NULL
);
GO

CREATE OR ALTER TRIGGER trg_DDL_SchemaLog
ON DATABASE
FOR DDL_TABLE_EVENTS, DDL_VIEW_EVENTS, DDL_PROCEDURE_EVENTS, DDL_FUNCTION_EVENTS
AS
BEGIN
    SET NOCOUNT ON;
    DECLARE @e xml = EVENTDATA();        -- one event per firing, so a variable is correct here

    INSERT INTO Lab.SchemaChangeLog (EventType, ObjectName, LoginName, CommandText)
    VALUES (@e.value('(/EVENT_INSTANCE/EventType)[1]',  'nvarchar(100)'),
            @e.value('(/EVENT_INSTANCE/SchemaName)[1]', 'nvarchar(128)') + N'.'
          + @e.value('(/EVENT_INSTANCE/ObjectName)[1]', 'nvarchar(128)'),
            ORIGINAL_LOGIN(),
            @e.value('(/EVENT_INSTANCE/TSQLCommand/CommandText)[1]', 'nvarchar(max)'));
END;
GO

CREATE TABLE Lab.DdlTest (ID int NOT NULL);
ALTER TABLE  Lab.DdlTest ADD Note nvarchar(50) NULL;
DROP TABLE   Lab.DdlTest;
GO

SELECT LogID, EventType, ObjectName, LoginName FROM Lab.SchemaChangeLog ORDER BY LogID;
GO

-- Required cleanup
DROP TRIGGER trg_DDL_SchemaLog ON DATABASE;

Result: CREATE_TABLE, ALTER_TABLE, DROP_TABLE for Lab.DdlTest, with your login.

Reasoning. DDL triggers belong to the database, not to a schema, so their names are not schema-qualified and they are listed in sys.triggers with parent_class_desc = 'DATABASE' (in SSMS: Database → Programmability → Database Triggers). EVENTDATA() returns an XML document describing the event; Day 27 covers XML querying in detail. For production auditing, SQL Server Audit is usually the better tool.


H. Knowledge check

  1. Understanding. An UPDATE changes 250 rows of a table with an AFTER UPDATE trigger. How many times does the trigger fire, and how many rows are in inserted?

  2. Debugging. An audit trigger passed every test, but auditors find that a nightly job that updates prices in bulk leaves only one audit row per run. What is the most likely bug?

  3. Query interpretation. Inside an AFTER UPDATE trigger, UPDATE(ListPrice) returns true, yet no price changed. How is that possible, and what should the trigger do about it?

  4. Understanding. Order lines are inserted together with a row that breaks a trigger’s rule, and the trigger runs THROW. Which rows remain in the table, and what happens to rows the trigger had already written to an audit table?

  5. Understanding. For each requirement choose a trigger, a constraint or a temporal table: (a) Quantity must be positive; (b) keep every past version of every product row; (c) “deleting” a customer must only mark them inactive.

Show the answers
  1. Once. inserted (and deleted) contain 250 rows.

  2. The trigger copies inserted into scalar variables, so it handles only one row per statement. Tests used single-row updates; the bulk job updates many rows in one statement. Rewrite the trigger as a set-based INSERT … SELECT joining inserted and deleted.

  3. UPDATE(column) only reports that the column was in the SET list (e.g. SET ListPrice = ListPrice). The trigger should use it as a quick exit when false, and compare inserted with deleted (with <>, or IS DISTINCT FROM for nullable columns) to find real changes.

  4. None of the inserted rows remain — the error rolls back the statement’s transaction. The trigger’s audit rows are rolled back too, because they were written in the same transaction.

  5. (a) CHECK constraint. (b) System-versioned temporal table. (c) INSTEAD OF DELETE trigger (or a procedure, if all deletes go through one).


I. Troubleshooting and cleanup

Error or symptom Cause Fix
Audit has fewer rows than rows changed Scalar-variable trigger Set-based INSERT … SELECT over inserted/deleted
Derived totals wrong after multi-row inserts, no error UPDATE … FROM with several matching source rows Aggregate inserted first
Msg 217: Maximum stored procedure, function, trigger, or view nesting level exceeded (limit 32). Triggers firing each other in a loop Add guards (UPDATE(col), TRIGGER_NESTLEVEL()); redesign
The transaction ended in the trigger. The batch has been aborted. ROLLBACK inside a trigger Usually prefer THROW, which rolls back and reports a meaningful error
Error when creating a trigger whose schema differs from the table’s A DML trigger must be in the same schema as its table Use Lab.trg_… for Lab tables
Extra (n rows affected) messages break client code Trigger without SET NOCOUNT ON Add it at the start
TRUNCATE bypassed the delete audit TRUNCATE doesn’t fire DML triggers Restrict TRUNCATE permission (it needs ALTER on the table)
A change “disappears” An INSTEAD OF trigger didn’t perform the action Check sys.triggers.is_instead_of_trigger

Cleanup. Make sure the DDL trigger from the stretch is gone, then reset the Lab copies. Dropping a table drops its triggers.

IF EXISTS (SELECT 1 FROM sys.triggers WHERE name = 'trg_DDL_SchemaLog' AND parent_class_desc = 'DATABASE')
    DROP TRIGGER trg_DDL_SchemaLog ON DATABASE;
DROP TABLE IF EXISTS Lab.SchemaChangeLog, Lab.OrderLine, Lab.ProductStock,
                     Lab.ProductPriceAudit, Lab.CustomerAudit, Lab.Product;

Lab.Customer is left in place; Day 20 starts by re-running the Day 17 setup, which replaces it with a fresh copy without triggers. Core data is unchanged.


J. Completion checklist, recap and references

Completion checklist

  • Worked example 1 showed 1 audit row for 3 changed prices; the fixed trigger recorded all 3.
  • The discontinued-product insert failed and left 0 order lines.
  • The soft-delete left 40 customers, 34 active.
  • Your customer audit ended with I 2, U 4, 42 customers, 34 active.
  • Tasks 1–3 match the success criteria; the DDL trigger has been dropped.

Recap

  • Triggers fire once per statement; inserted and deleted hold all affected rows — 0, 1 or many.
  • Never copy inserted into scalar variables; aggregate before updating other tables.
  • AFTER triggers see the completed change; INSTEAD OF triggers replace it.
  • THROW in a trigger rolls back the statement and everything the triggers did.
  • UPDATE(col) means “in the SET list”, not “changed”; compare inserted with deleted.
  • Prefer constraints, temporal tables and procedures when they can do the job; triggers are hidden logic.

Next: Day 20 — Error handling: TRY…CATCH, THROW, XACT_STATE() and XACT_ABORT, applied to the Day 17 procedures.

References (Microsoft Learn)