MS SQL Server
Triggers
What Is a Trigger?
A trigger is a special stored procedure that SQL Server executes automatically whenever a specific data event occurs on a table or view. You do not call a trigger manually — it fires in response to an INSERT, UPDATE, or DELETE statement, running as part of the same transaction. This makes triggers powerful for enforcing business rules that cannot be expressed as constraints, maintaining audit trails, and keeping derived data synchronised without requiring application code to handle it.
SQL Server supports two timing models. A AFTER trigger (also called a FOR trigger) fires after the data modification has completed and the row has been written. An INSTEAD OF trigger fires in place of the data modification — the trigger body decides whether to carry out the change at all, and how. AFTER triggers are the most common type. INSTEAD OF triggers are used primarily on views to make non-updatable views writable.
- Triggers fire automatically — they cannot be called directly like a stored procedure
- AFTER triggers run after the INSERT, UPDATE, or DELETE has completed
- INSTEAD OF triggers replace the data modification — the trigger controls what actually happens
- Every trigger has access to two virtual tables:
insertedanddeleted insertedholds the new row values — available in INSERT and UPDATE triggersdeletedholds the old row values — available in DELETE and UPDATE triggers- Triggers run inside the same transaction as the statement that fired them — a ROLLBACK inside the trigger rolls back everything
-- AFTER INSERT trigger: log every new order into an audit table.
-- The 'inserted' virtual table contains the rows just added to Orders.
-- First create the audit table to receive the log entries
CREATE TABLE OrderAudit (
AuditId INT IDENTITY PRIMARY KEY,
OrderId INT NOT NULL,
UserId INT NOT NULL,
Total DECIMAL(10,2) NOT NULL,
Action NVARCHAR(10) NOT NULL, -- 'INSERT', 'UPDATE', or 'DELETE'
ActionDate DATETIME2 NOT NULL DEFAULT GETDATE(),
ActionBy NVARCHAR(128) NOT NULL DEFAULT SYSTEM_USER
);
-- Create the trigger on the Orders table
CREATE TRIGGER trg_Orders_AfterInsert
ON dbo.Orders
AFTER INSERT -- fires after every INSERT on Orders
AS
BEGIN
SET NOCOUNT ON; -- suppress row-count messages from inside trigger
INSERT INTO dbo.OrderAudit (OrderId, UserId, Total, Action)
SELECT
i.OrderId,
i.UserId,
i.Total,
'INSERT'
FROM inserted i; -- 'inserted' contains the newly added rows
END;-- Now insert a new order:
INSERT INTO dbo.Orders (OrderId, UserId, Total, OrderDate, Status)
VALUES (1055, 14, 124.99, '2024-07-01', 'pending');
-- The trigger fires automatically -- check the audit table:
SELECT * FROM OrderAudit WHERE OrderId = 1055;
AuditId | OrderId | UserId | Total | Action | ActionDate | ActionBy
--------|---------|--------|--------|--------|-------------------------|----------
1 | 1055 | 14 | 124.99 | INSERT | 2024-07-01 09:14:22.000 | DPLX\svc_app
(1 row affected)
- The trigger fires automatically the moment the INSERT on Orders completes — no application code needed
SET NOCOUNT ONsuppresses the extra "1 row(s) affected" message that the INSERT inside the trigger would otherwise send back to the caller- The
insertedtable can contain multiple rows if the original INSERT statement added more than one row — the trigger SELECT handles all of them at once SYSTEM_USERcaptures the SQL Server login that executed the INSERT — useful for accountability in audit trails
AFTER UPDATE Trigger — The inserted and deleted Tables
An UPDATE trigger has access to both inserted (the new values) and deleted (the old values). Joining them on the primary key gives you a before-and-after view of every changed row in a single result set. This is the standard pattern for change tracking — capture what the row looked like before the update and what it looks like after, then log or act on the difference.
-- AFTER UPDATE trigger: detect price changes on Products and log the before/after.
-- JOIN inserted and deleted on the PK to see what changed on each row.
CREATE TRIGGER trg_Products_AfterUpdate
ON dbo.Products
AFTER UPDATE
AS
BEGIN
SET NOCOUNT ON;
-- only act if the Price column was actually changed
-- IF UPDATE(Price) returns true when Price is included in the SET clause
IF UPDATE(Price)
BEGIN
INSERT INTO dbo.PriceChangeLog
(ProductId, OldPrice, NewPrice, ChangedDate, ChangedBy)
SELECT
i.ProductId,
d.Price AS OldPrice, -- deleted = row before the update
i.Price AS NewPrice, -- inserted = row after the update
GETDATE(),
SYSTEM_USER
FROM inserted i
JOIN deleted d ON i.ProductId = d.ProductId -- match old and new by PK
WHERE i.Price <> d.Price; -- only rows where Price genuinely changed
END;
END;
-- Trigger in action:
UPDATE dbo.Products SET Price = 319.99 WHERE ProductId = 5; -- Monitor 27-inch
-- Check the log:
SELECT * FROM PriceChangeLog WHERE ProductId = 5;(1 row affected)
-- PriceChangeLog result:
LogId | ProductId | OldPrice | NewPrice | ChangedDate | ChangedBy
------|-----------|----------|----------|-------------------------|----------
1 | 5 | 299.99 | 319.99 | 2024-07-01 10:05:11.000 | DPLX\svc_app
(1 row affected)
IF UPDATE(Price)checks whether the Price column was included in the SET clause — it prevents the trigger body from running unnecessarily on updates to other columns- Joining
insertedanddeletedonProductIdaligns the before and after values for each updated row WHERE i.Price <> d.Priceguards against no-op updates where the value is set to the same number it already held- For multi-row UPDATEs both
insertedanddeletedcontain multiple rows — the JOIN handles all of them correctly
AFTER DELETE Trigger
A DELETE trigger has access only to deleted — the rows that were just removed. The inserted table is empty for a DELETE trigger. The most common use is a soft-delete pattern: rather than allowing the row to be permanently gone, the trigger copies it to an archive table before the transaction completes, or raises an error to prevent the deletion entirely under certain conditions.
-- AFTER DELETE trigger: archive deleted orders before they disappear.
-- 'deleted' holds the rows that were just removed from Orders.
CREATE TRIGGER trg_Orders_AfterDelete
ON dbo.Orders
AFTER DELETE
AS
BEGIN
SET NOCOUNT ON;
INSERT INTO dbo.OrdersArchive
(OrderId, UserId, Total, OrderDate, Status, DeletedDate, DeletedBy)
SELECT
d.OrderId,
d.UserId,
d.Total,
d.OrderDate,
d.Status,
GETDATE(),
SYSTEM_USER
FROM deleted d; -- deleted contains every row removed by the DELETE statement
END;
-- Trigger that PREVENTS deletion of shipped or delivered orders:
CREATE TRIGGER trg_Orders_PreventDelete
ON dbo.Orders
AFTER DELETE
AS
BEGIN
SET NOCOUNT ON;
IF EXISTS (
SELECT 1 FROM deleted
WHERE Status IN ('shipped', 'delivered') -- check the removed rows
)
BEGIN
RAISERROR('Cannot delete shipped or delivered orders.', 16, 1);
ROLLBACK TRANSACTION; -- rolls back the DELETE and the trigger together
END;
END;DELETE FROM dbo.Orders WHERE OrderId = 1042;
Msg 50000, Level 16, State 1:
Cannot delete shipped or delivered orders.
-- The transaction is rolled back -- order 1042 still exists in Orders
-- The archive trigger fires first and inserts the row -- but ROLLBACK undoes that too
-- Deleting a pending order succeeds:
DELETE FROM dbo.Orders WHERE OrderId = 1055;
(1 row affected)
-- Order 1055 archived automatically by trg_Orders_AfterDelete
deletedholds the rows being removed — they are already gone from Orders when the trigger runs but still fully accessible through this virtual tableRAISERRORfollowed byROLLBACK TRANSACTIONaborts the DELETE and rolls back all changes in the current transaction, including any work the trigger itself did- Two AFTER DELETE triggers exist on Orders — SQL Server fires them in an unspecified order; use
sp_settriggerorderif the sequence matters - The prevention trigger checks the
deletedtable, not the Orders table — by the time AFTER triggers run the rows are already removed from the base table
INSTEAD OF Trigger — Controlling Updates on a View
An INSTEAD OF trigger intercepts the data modification before it reaches the underlying table and replaces it entirely. The trigger body decides what actually happens — it can reroute the change to different tables, apply transformations, enforce custom rules, or perform nothing at all. INSTEAD OF triggers are the standard mechanism for making views over multiple tables behave as if they were a single writable table.
-- View joining Users and UserProfiles -- not directly updatable without a trigger.
CREATE VIEW vw_CustomerSummary AS
SELECT
u.UserId,
u.FirstName,
u.LastName,
u.Email,
up.Bio,
up.AvatarUrl
FROM dbo.Users u
LEFT JOIN dbo.UserProfiles up ON u.UserId = up.UserId;
-- INSTEAD OF UPDATE: intercept updates to the view and route them
-- to the correct underlying tables.
CREATE TRIGGER trg_CustomerSummary_InsteadOfUpdate
ON dbo.vw_CustomerSummary
INSTEAD OF UPDATE
AS
BEGIN
SET NOCOUNT ON;
-- update Users table with name/email changes
UPDATE u
SET u.FirstName = i.FirstName,
u.LastName = i.LastName,
u.Email = i.Email
FROM dbo.Users u
JOIN inserted i ON u.UserId = i.UserId;
-- update UserProfiles with bio/avatar changes (insert if profile doesn't exist)
MERGE dbo.UserProfiles AS target
USING inserted AS source ON target.UserId = source.UserId
WHEN MATCHED THEN
UPDATE SET Bio = source.Bio, AvatarUrl = source.AvatarUrl
WHEN NOT MATCHED THEN
INSERT (UserId, Bio, AvatarUrl, JoinDate)
VALUES (source.UserId, source.Bio, source.AvatarUrl, CAST(GETDATE() AS DATE));
END;UPDATE dbo.vw_CustomerSummary
SET Bio = 'SQL Server enthusiast', Email = 'priya.new@email.com'
WHERE UserId = 23;
(1 row affected)
-- The INSTEAD OF trigger routed:
-- Email change → dbo.Users (updated)
-- Bio change → dbo.UserProfiles (updated or inserted)
-- Neither table was touched directly by the caller
- The UPDATE on the view never reaches the underlying tables directly — the INSTEAD OF trigger intercepts it entirely
- The trigger uses
insertedto access the new values the caller wanted to write, then routes each column to the correct table MERGEinside the trigger handles the case where a UserProfiles row does not yet exist — it inserts one rather than failing- INSTEAD OF triggers are the cleanest way to make complex views fully updatable without exposing the underlying schema to the caller
Managing and Disabling Triggers
Triggers are database objects that can be listed, disabled, re-enabled, and dropped using DDL statements. Disabling a trigger does not remove it — it simply stops it from firing until it is re-enabled. This is useful during bulk data loads where trigger overhead would be significant, or during debugging to isolate whether a trigger is causing unexpected behaviour. The system catalogue view sys.triggers lists every trigger in the database along with its parent object and current enabled state.
-- Disable a trigger temporarily (e.g. during a bulk load)
DISABLE TRIGGER trg_Orders_AfterInsert ON dbo.Orders;
-- Re-enable it once the bulk load is complete
ENABLE TRIGGER trg_Orders_AfterInsert ON dbo.Orders;
-- Drop a trigger permanently
DROP TRIGGER IF EXISTS trg_Orders_AfterInsert;
-- List all triggers in the database with their parent table and status
SELECT
t.name AS TriggerName,
OBJECT_NAME(t.parent_id) AS TableName,
t.type_desc AS TriggerType,
t.is_disabled AS IsDisabled,
t.create_date AS Created,
t.modify_date AS LastModified
FROM sys.triggers t
WHERE t.parent_class = 1 -- 1 = table triggers (not database-level triggers)
ORDER BY TableName, TriggerName;-------------------------------------|--------------|----------------|------------|--------
trg_Orders_AfterDelete | Orders | SQL_TRIGGER | 0 | 2024-07-01
trg_Orders_AfterInsert | Orders | SQL_TRIGGER | 1 | 2024-07-01
trg_Orders_PreventDelete | Orders | SQL_TRIGGER | 0 | 2024-07-01
trg_Products_AfterUpdate | Products | SQL_TRIGGER | 0 | 2024-07-01
trg_CustomerSummary_InsteadOfUpdate | vw_CustomerSummary | SQL_TRIGGER | 0 | 2024-07-01
(5 rows affected)
-- trg_Orders_AfterInsert shows IsDisabled = 1 -- currently disabled
DISABLE TRIGGERandENABLE TRIGGERrequire the table name — unlike DROP, they do not work with just the trigger name aloneis_disabled = 1confirms trg_Orders_AfterInsert is currently off — no audit rows will be written during the bulk load periodDROP TRIGGER IF EXISTSis the safe drop syntax — it does not error if the trigger does not exist- Use a consistent naming convention for triggers — the
trg_TableName_EventTypepattern makes the catalogue easy to scan at a glance
Lesson Summary
| Concept | What It Means | Example |
|---|---|---|
| Trigger | Stored procedure that fires automatically on INSERT, UPDATE, or DELETE | CREATE TRIGGER ... ON dbo.Orders AFTER INSERT |
| AFTER trigger | Fires after the data modification completes — most common type | Audit logging, cascade logic, validation |
| INSTEAD OF trigger | Replaces the modification entirely — trigger decides what happens | Making complex views updatable |
| inserted table | Virtual table holding new row values — available in INSERT and UPDATE triggers | SELECT i.OrderId FROM inserted i |
| deleted table | Virtual table holding old row values — available in DELETE and UPDATE triggers | SELECT d.Price FROM deleted d |
| UPDATE trigger pattern | JOIN inserted and deleted on PK to see before and after values per row | JOIN deleted d ON i.ProductId = d.ProductId |
| IF UPDATE(col) | Returns true if the specified column was included in the SET clause | IF UPDATE(Price) |
| ROLLBACK in trigger | Rolls back the triggering statement and the trigger itself — use to enforce rules | RAISERROR(...); ROLLBACK TRANSACTION; |
| SET NOCOUNT ON | Suppresses extra row-count messages from statements inside the trigger | Always include at the top of every trigger body |
| DISABLE / ENABLE TRIGGER | Temporarily stops or restarts a trigger without dropping it | DISABLE TRIGGER trg_Name ON dbo.Table |
| sys.triggers | System catalogue listing every trigger with its parent object and enabled state | SELECT * FROM sys.triggers WHERE parent_class = 1 |
Practice Questions
Practice 1. What are the two timing models for triggers in SQL Server, and how do they differ?
Practice 2. Inside an UPDATE trigger, how do you see both the old and new values of a changed row at the same time?
Practice 3. What does IF UPDATE(Price) check inside a trigger?
Practice 4. Why should you include SET NOCOUNT ON at the top of every trigger body?
Practice 5. How do you temporarily stop a trigger from firing without removing it from the database?
Quiz
Quiz 1. Which virtual table is available inside a DELETE trigger?
Quiz 2. A trigger uses RAISERROR followed by ROLLBACK TRANSACTION. What happens to the original INSERT that fired the trigger?
Quiz 3. What is the primary use case for an INSTEAD OF trigger on a view?
Quiz 4. An UPDATE statement changes 50 rows at once. How many times does the AFTER UPDATE trigger fire?
Quiz 5. You need to audit price changes but only when the Price column is actually modified, not on every UPDATE. Which technique achieves this efficiently?
Next up — SQL Server Security and Roles — How logins, users, roles, and permissions control who can access and modify data in your database.