Triggers
Code that runs by itself when data changes: AFTER triggers, the inserted and deleted tables, the single-row trigger bug, an audit trail, blocking changes with THROW, INSTEAD OF triggers on views, managing triggers, and when not to use them.
DROP TABLE IF EXISTS dbo.PriceHistory;CREATE TABLE dbo.PriceHistory ( ChangeId int NOT NULL IDENTITY PRIMARY KEY, ProductId int NOT NULL, OldPrice decimal(10,2) NOT NULL, NewPrice decimal(10,2) NOT NULL, ChangedAt datetime2(0) NOT NULL DEFAULT SYSDATETIME());inserted and deleted
| Statement | `inserted` holds | `deleted` holds |
|---|---|---|
CREATE OR ALTER TRIGGER dbo.trg_Products_PriceHistoryON dbo.ProductsAFTER UPDATEASBEGIN SET NOCOUNT ON; DECLARE @Id int, @Old decimal(10,2), @New decimal(10,2); SELECT @Id = i.ProductId, @Old = d.Price, @New = i.Price FROM inserted AS i JOIN deleted AS d ON d.ProductId = i.ProductId; IF @Old <> @New INSERT INTO dbo.PriceHistory (ProductId, OldPrice, NewPrice) VALUES (@Id, @Old, @New);END;GOUPDATE dbo.Products SET Price = 19.99 WHERE ProductId = 1; -- one rowUPDATE dbo.Products SET Price = Price * 1.10 WHERE CategoryId = 5; -- two rows SELECT COUNT(*) AS PricesChanged, (SELECT COUNT(*) FROM dbo.PriceHistory) AS RowsLoggedFROM dbo.ProductsWHERE ProductId = 1 OR CategoryId = 5;PricesChanged RowsLogged
------------- ----------
3 2SELECT @Id = … kept only one of them.
CREATE OR ALTER TRIGGER dbo.trg_Products_PriceHistoryON dbo.ProductsAFTER UPDATEASBEGIN IF @@ROWCOUNT = 0 RETURN; -- the statement changed no rows SET NOCOUNT ON; INSERT INTO dbo.PriceHistory (ProductId, OldPrice, NewPrice) SELECT i.ProductId, d.Price, i.Price FROM inserted AS i JOIN deleted AS d ON d.ProductId = i.ProductId WHERE i.Price <> d.Price;END;GOTRUNCATE TABLE dbo.PriceHistory; UPDATE dbo.Products SET Price = Price * 1.10 WHERE CategoryId = 5; -- two pricesUPDATE dbo.Products SET Stock = Stock + 1 WHERE ProductId = 2; -- no price changeUPDATE dbo.Products SET Price = 5 WHERE CategoryId = 7; -- no rows at all SELECT ChangeId, ProductId, OldPrice, NewPrice FROM dbo.PriceHistory ORDER BY ChangeId;ChangeId ProductId OldPrice NewPrice
-------- --------- -------- --------
1 11 13.75 15.13
2 10 59.40 65.34IF @@ROWCOUNT = 0 RETURN; skips the work in that case. It must be the first line, because SET and most other statements reset @@ROWCOUNT.
An audit trail for every change
DROP TABLE IF EXISTS dbo.ProductAudit;CREATE TABLE dbo.ProductAudit ( AuditId int NOT NULL IDENTITY PRIMARY KEY, Action varchar(6) NOT NULL, ProductId int NOT NULL, OldPrice decimal(10,2) NULL, NewPrice decimal(10,2) NULL);GOCREATE OR ALTER TRIGGER dbo.trg_Products_AuditON dbo.ProductsAFTER INSERT, UPDATE, DELETEASBEGIN IF @@ROWCOUNT = 0 RETURN; SET NOCOUNT ON; INSERT INTO dbo.ProductAudit (Action, ProductId, OldPrice, NewPrice) SELECT CASE WHEN d.ProductId IS NULL THEN 'INSERT' WHEN i.ProductId IS NULL THEN 'DELETE' ELSE 'UPDATE' END, COALESCE(i.ProductId, d.ProductId), d.Price, i.Price FROM inserted AS i FULL OUTER JOIN deleted AS d ON d.ProductId = i.ProductId ORDER BY COALESCE(i.ProductId, d.ProductId); -- AuditIds follow ProductIdEND;GOINSERT INTO dbo.Products (Name, CategoryId, Price, Stock, Unit, AddedOn)VALUES (N'Spirit level 600 mm', 1, 15.00, 20, N'each', '2024-07-01'), (N'Wood glue 1 L', 4, 8.40, 35, N'bottle', '2024-07-01'); UPDATE dbo.Products SET Price = 14.50 WHERE ProductId = 13;DELETE FROM dbo.Products WHERE ProductId > 12; SELECT AuditId, Action, ProductId, OldPrice, NewPriceFROM dbo.ProductAuditORDER BY AuditId;AuditId Action ProductId OldPrice NewPrice
------- ------ --------- -------- --------
1 INSERT 13 NULL 15.00
2 INSERT 14 NULL 8.40
3 UPDATE 13 15.00 14.50
4 DELETE 13 14.50 NULL
5 DELETE 14 8.40 NULLBlocking a change
CREATE OR ALTER TRIGGER dbo.trg_Products_PriceGuardON dbo.ProductsAFTER UPDATEASBEGIN IF @@ROWCOUNT = 0 RETURN; SET NOCOUNT ON; IF EXISTS (SELECT 1 FROM inserted AS i JOIN deleted AS d ON d.ProductId = i.ProductId WHERE i.Price > d.Price * 1.5) THROW 50010, N'A price rise of more than half needs approval.', 1;END;GOUPDATE dbo.Products SET Price = Price * 2 WHERE ProductId = 5; -- Sand: 4.10 -> 8.20PRINT N'Not reached';GOSELECT Name, Price FROM dbo.Products WHERE ProductId = 5;Msg 50010, Level 16, State 1, Procedure trg_Products_PriceGuard, Line 13 A price rise of more than half needs approval. Name Price ---------- ----- Sand 25 kg 4.10
INSTEAD OF triggers
CREATE OR ALTER VIEW dbo.ProductCatalogueASSELECT p.ProductId, p.Name, c.Name AS Category, p.PriceFROM dbo.Products AS pJOIN dbo.Categories AS c ON c.CategoryId = p.CategoryId;GOCREATE OR ALTER TRIGGER dbo.trg_ProductCatalogue_InsertON dbo.ProductCatalogueINSTEAD OF INSERTASBEGIN SET NOCOUNT ON; INSERT INTO dbo.Products (Name, CategoryId, Price, Stock, Unit, AddedOn) SELECT i.Name, c.CategoryId, i.Price, 0, N'each', '2024-07-01' FROM inserted AS i JOIN dbo.Categories AS c ON c.Name = i.Category;END;GOINSERT INTO dbo.ProductCatalogue (Name, Category, Price)VALUES (N'Pipe wrench', N'Plumbing', 16.00), (N'Ear defenders', N'Safety', 11.50); SELECT ProductId, Name, Category, PriceFROM dbo.ProductCatalogueWHERE ProductId > 12ORDER BY ProductId;ProductId Name Category Price
--------- ------------- -------- -----
15 Ear defenders Safety 11.50
16 Pipe wrench Plumbing 16.00Managing triggers
sys.triggers lists them, and a trigger can be switched off and on without dropping it:
DISABLE TRIGGER dbo.trg_Products_PriceGuard ON dbo.Products; SELECT t.name AS TriggerName, OBJECT_NAME(t.parent_id) AS OnObject, t.is_instead_of_trigger AS InsteadOf, t.is_disabled AS DisabledFROM sys.triggers AS tORDER BY t.name; ENABLE TRIGGER dbo.trg_Products_PriceGuard ON dbo.Products;TriggerName OnObject InsteadOf Disabled --------------------------- ---------------- --------- -------- trg_ProductCatalogue_Insert ProductCatalogue 1 0 trg_Products_Audit Products 0 0 trg_Products_PriceGuard Products 0 1 trg_Products_PriceHistory Products 0 0
sp_settriggerorder can choose the first and last.)
When not to use a trigger
| Need | Better tool |
|---|---|
Show a hint
Show the solution
DROP TABLE IF EXISTS dbo.StockAlerts;CREATE TABLE dbo.StockAlerts (ProductId int, Name nvarchar(60));GOCREATE OR ALTER TRIGGER dbo.trg_Products_StockAlertON dbo.ProductsAFTER UPDATEASBEGIN IF @@ROWCOUNT = 0 RETURN; SET NOCOUNT ON; INSERT INTO dbo.StockAlerts (ProductId, Name) SELECT i.ProductId, i.Name FROM inserted AS i JOIN deleted AS d ON d.ProductId = i.ProductId WHERE d.Stock > 0 AND i.Stock = 0;END;GOUPDATE dbo.Products SET Stock = 0 WHERE ProductId IN (1, 3); SELECT ProductId, Name FROM dbo.StockAlerts ORDER BY ProductId;ProductId Name
--------- ----------------
1 Claw hammer
3 Tape measure 8 mRemember
A trigger runs automatically for INSERT, UPDATE or DELETE on its table or view. inserted holds new rows, deleted old rows; an UPDATE fills both. Triggers fire once per statement: never copy inserted or deleted into variables; write set-based code. Start with IF @@ROWCOUNT = 0 RETURN; then SET NOCOUNT ON. AFTER triggers run inside the statement’s transaction: THROW in a trigger undoes the change and ends the batch. INSTEAD OF triggers replace the statement; they make complex views writable. sys.triggers lists triggers; DISABLE / ENABLE TRIGGER switch them off and on. Prefer constraints, OUTPUT or temporal tables when they do the job; triggers are hidden and add cost.