Day 19: Triggers
On this page
By the end of this lesson you can
- Create
AFTERandINSTEAD OFDML triggers and explain when each fires. - Use the
insertedanddeletedpseudo-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
- Do: One trigger for all three actions. A
FULL OUTER JOINofinsertedanddeletedtells you the action per row. - 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 - Observe: Commands completed successfully.
- Why it matters: One set-based statement handles any number of rows and all three actions.
IS DISTINCT FROMtreats NULL → value and value → NULL as changes (Email allows NULL), which<>would miss. - Confirm:
SELECT name, is_instead_of_trigger FROM sys.triggers WHERE parent_id = OBJECT_ID('Lab.Customer');→trg_Customer_Audit 0andtrg_Customer_SoftDelete 1.
Step 2 — Multi-row insert
- Do: Add two customers from the import in one statement.
- 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; - Observe: two
Irows, for 41 and 42. - Why it matters: Proves the trigger is not limited to one row.
- Confirm: the old-value columns are NULL for inserts.
Step 3 — Multi-row update, and an update that changes nothing
- Do: Upgrade London business customers to VIP; then run an update that touches rows without changing audited columns.
- 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; - Observe: (2 rows affected) then (4 rows affected); the audit shows 2
Urows: customers 13 and 39, Business → VIP. The Ireland update produced nothing. - Why it matters: Customer 39 is inactive (soft-deleted in worked example 4) but was still updated — the
WHEREdidn’t filter onIsActive. Audits capture what actually happened, not what was intended. - Confirm:
SELECT COUNT(*) FROM Lab.CustomerAudit;→ 4.
Step 4 — Delete, and watch the triggers interact
- Do: Delete the two new customers.
- 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; - Observe: For each customer: the
Irow from step 2, then aUrow withIsActive1 → 0. NoDrows. - Why it matters: The
INSTEAD OF DELETEtrigger replaced the delete with anUPDATE, and thatUPDATEfired theAFTERaudit trigger (a nested trigger). The audit correctly records what really happened: a soft delete. - Confirm:
SELECT COUNT(*) FROM Lab.Customer;→ 42.
Step 5 — Summarise
- Do: Check the totals.
- 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; - Observe:
I 2,U 4;42customers,34active. - Why it matters: Totals you can predict before running are the best test of trigger logic.
- 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
-
Understanding. An
UPDATEchanges 250 rows of a table with anAFTER UPDATEtrigger. How many times does the trigger fire, and how many rows are ininserted? -
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?
-
Query interpretation. Inside an
AFTER UPDATEtrigger,UPDATE(ListPrice)returns true, yet no price changed. How is that possible, and what should the trigger do about it? -
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? -
Understanding. For each requirement choose a trigger, a constraint or a temporal table: (a)
Quantitymust be positive; (b) keep every past version of every product row; (c) “deleting” a customer must only mark them inactive.
Show the answers
-
Once.
inserted(anddeleted) contain 250 rows. -
The trigger copies
insertedinto 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-basedINSERT … SELECTjoininginsertedanddeleted. -
UPDATE(column)only reports that the column was in theSETlist (e.g.SET ListPrice = ListPrice). The trigger should use it as a quick exit when false, and compareinsertedwithdeleted(with<>, orIS DISTINCT FROMfor nullable columns) to find real changes. -
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.
-
(a)
CHECKconstraint. (b) System-versioned temporal table. (c)INSTEAD OF DELETEtrigger (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;
insertedanddeletedhold all affected rows — 0, 1 or many. - Never copy
insertedinto scalar variables; aggregate before updating other tables. AFTERtriggers see the completed change;INSTEAD OFtriggers replace it.THROWin a trigger rolls back the statement and everything the triggers did.UPDATE(col)means “in theSETlist”, not “changed”; compareinsertedwithdeleted.- 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.