Tag: Triggers

  • DML Triggers in SQL Server: AFTER vs INSTEAD OF, and the inserted/deleted Tables

    DML Triggers in SQL Server: AFTER vs INSTEAD OF, and the inserted/deleted Tables

    Chapter 5 asked “where do I enforce a business rule” and pointed to triggers whenever logic needs to reach across rows or tables — something CHECK constraints structurally can’t do. This is that tool, in full. A trigger is code that runs automatically in response to an INSERT/UPDATE/DELETE. Getting the two DML trigger types right — and understanding their special tables — matters a lot in production, because a trigger bug affects every write to the table, silently.

    AFTER vs INSTEAD OF(same event, two very different timings)UPDATE Account SET balance=…AFTER triggerrows are alreadychanged by the timethis code runsextra logic (audit, etc)DELETE FROM Account WHERE…INSTEAD OF triggeroriginal DELETE isREPLACED entirely —never happens unlessthe trigger body does itinserteddeletedboth special tables, populated per STATEMENTGotcha: triggers fire ONCE per statement, not once per row —always JOIN to inserted/deleted, never grab one scalar row. 📌

    AFTER Trigger: Audit Logging

    CREATE TRIGGER trg_Account_AuditBalance
    ON dbo.Account
    AFTER UPDATE
    AS
    BEGIN
        SET NOCOUNT ON;
        IF UPDATE(balance)
        BEGIN
            INSERT INTO dbo.AccountAudit (account_id, old_balance, new_balance)
            SELECT i.account_id, d.balance, i.balance
            FROM inserted i
            INNER JOIN deleted d ON d.account_id = i.account_id
            WHERE i.balance <> d.balance;
        END
    END;

    UPDATE(balance) is a trigger-specific function that returns true only if the balance column was included in the UPDATE’s SET list — a cheap early-exit that avoids doing audit work on updates that never touched the column you actually care about.

    The Two Special Tables

    inserted New values (INSERT & UPDATE) deleted Old values (UPDATE & DELETE)

    On an UPDATE, both are populated simultaneously — exactly how the audit trigger above compares old vs. new balance by joining them together on the primary key. On a plain INSERT, only inserted has rows; on a plain DELETE, only deleted does.

    The Bug That Bites in Production

    Triggers fire once per statement, operating on the whole batch of affected rows, not once per row. A trigger written assuming only one row was updated (using a scalar variable instead of joining to inserted/deleted) will silently process only one arbitrary row and miss the rest of a multi-row UPDATE — with no error, just quietly incomplete auditing.

    -- The exact bug: looks reasonable, is completely wrong for multi-row updates
    CREATE TRIGGER trg_Bad_AuditBalance ON dbo.Account AFTER UPDATE AS
    BEGIN
        DECLARE @id INT, @newBalance DECIMAL(10,2);
        SELECT @id = account_id, @newBalance = balance FROM inserted; -- only grabs ONE row
        INSERT INTO dbo.AccountAudit (account_id, new_balance) VALUES (@id, @newBalance);
    END;
    
    -- Prove the bug: update multiple rows in one statement, check the audit table
    UPDATE dbo.Account SET balance = balance * 1.01 WHERE balance > 0; -- affects many rows
    SELECT COUNT(*) FROM dbo.AccountAudit; -- only 1 row logged, not one per account updated
    Common mistake: Testing a trigger only against single-row UPDATE statements during development, where the scalar-variable bug above produces correct-looking results by coincidence. It fails silently the first time a real batch UPDATE or bulk import touches multiple rows at once — always test triggers against multi-row operations before trusting them.

    INSTEAD OF: Replacing the Operation Entirely

    CREATE TRIGGER trg_Account_PreventDeleteIfFunded
    ON dbo.Account
    INSTEAD OF DELETE
    AS
    BEGIN
        SET NOCOUNT ON;
        IF EXISTS (SELECT 1 FROM deleted WHERE balance > 0)
        BEGIN
            RAISERROR('Cannot delete an account with a positive balance.', 16, 1);
            RETURN;
        END
        DELETE FROM dbo.Account WHERE account_id IN (SELECT account_id FROM deleted);
    END;

    INSTEAD OF triggers replace the operation entirely — the original INSERT/UPDATE/DELETE never happens unless the trigger body performs it itself. This is the exact mechanism that makes a JOIN-based view (Chapter 6) updatable despite SQL Server’s own restrictions: an INSTEAD OF trigger on the view can manually split the write across the correct base tables, however that logic needs to work.

    Practice tip: Rebuild both example triggers, then deliberately run a multi-row UPDATE against Account and confirm the AFTER trigger logs every changed row correctly. Then try deleting a funded account and confirm the INSTEAD OF trigger blocks it with the custom error message.

    Enjoyed this?

    Subscribe to get every new SQL Server lesson as soon as it’s published, and share it with a developer who’d find it useful.

    📡 Subscribe via RSS  | 
    Share on X  | 
    Share on LinkedIn  | 
    Share on Facebook

    Want the full structured course with quizzes, projects, and 10+ exercises per chapter? Check out SQL Server for Developers & DBAs, coming soon on this site.

  • DDL and Logon Triggers in SQL Server: Auditing Schema Changes and Login Restrictions

    DDL and Logon Triggers in SQL Server: Auditing Schema Changes and Login Restrictions

    Beyond DML, SQL Server triggers can fire on schema changes (CREATE/ALTER/DROP — Fundamentals Chapter 2’s DDL statements) and even login attempts — powerful, and in the case of logon triggers, genuinely risky if you get it wrong, in a way DML triggers never are.

    Two More Places Triggers Fire(schema changes, and login attempts)CREATE / ALTER / DROP TABLEDDL triggerEVENTDATA() captureswho + what + whenSchemaChangeLog rowwho dropped what, and whenLOGIN attemptLOGON triggersits BETWEEN you andthe ability to connect — a bughere can lock out EVERYONE,admins included ⚠DAC (sqlcmd -A)the one door triggers can’t blockGotcha: test logon triggers in non-prod first, and know yourDAC login path BEFORE ever enabling one in production. 📌

    DDL Trigger: Auditing Schema Changes

    CREATE TRIGGER trg_LogSchemaChanges
    ON DATABASE
    FOR CREATE_TABLE, ALTER_TABLE, DROP_TABLE
    AS
    BEGIN
        SET NOCOUNT ON;
        DECLARE @data XML = EVENTDATA();
        INSERT INTO dbo.SchemaChangeLog (event_type, object_name, changed_by)
        VALUES (
            @data.value('(/EVENT_INSTANCE/EventType)[1]', 'NVARCHAR(100)'),
            @data.value('(/EVENT_INSTANCE/ObjectName)[1]', 'NVARCHAR(200)'),
            @data.value('(/EVENT_INSTANCE/LoginName)[1]', 'NVARCHAR(100)')
        );
    END;

    EVENTDATA() returns an XML document describing exactly what changed and who changed it — a genuinely useful audit trail for compliance-sensitive environments, where “who dropped that table, and when” is a question that needs a real answer, not a guess from backup timestamps.

    -- Trigger it and see the audit row appear
    CREATE TABLE dbo.Scratch_DDLTest (id INT);
    SELECT * FROM dbo.SchemaChangeLog ORDER BY changed_by DESC; -- your CREATE TABLE is logged
    DROP TABLE dbo.Scratch_DDLTest;

    Logon Triggers: Powerful, and Genuinely Dangerous

    A broken logon trigger can lock out EVERY login, including administrators

    A logon trigger fires when a login session is established — used for things like restricting logins by time of day or capping concurrent sessions. Unlike every other trigger type in this chapter, a logon trigger sits between you and the ability to connect at all — an error in its logic, or a bug that always evaluates to “deny,” locks out every single login attempt, with no normal way back in.

    CREATE TRIGGER trg_RestrictOffHoursLogin ON ALL SERVER WITH EXECUTE AS 'sa' FOR LOGON AS
    BEGIN
        IF DATEPART(HOUR, GETDATE()) NOT BETWEEN 6 AND 22
           AND ORIGINAL_LOGIN() NOT IN ('sa', 'app_admin')
            ROLLBACK; -- rejects the connection
    END;
    Critical safety note: Before deploying any logon trigger, know how to reach the Dedicated Administrator Connection (DAC) — a special, separate connection path (sqlcmd -A) that logon triggers cannot block, reserved exactly for this recovery scenario. Test in a non-production environment first, and always keep a DAC-based rollback plan ready before enabling anything that can reject logins.

    Test extremely carefully, typically with that recovery plan via DAC in case something goes wrong — this is one of the very few features in this entire course where “just try it and see” is genuinely bad advice.

    Practice tip: Rather than testing a logon trigger live, read through the exact trigger definition above and trace what happens for a login attempt at 3am from a non-admin account, versus one from app_admin at the same hour. Understanding the logic on paper first, before ever enabling it, is the responsible way to work with this specific feature.

    Enjoyed this?

    Subscribe to get every new SQL Server lesson as soon as it’s published, and share it with a developer who’d find it useful.

    📡 Subscribe via RSS  | 
    Share on X  | 
    Share on LinkedIn  | 
    Share on Facebook

    Want the full structured course with quizzes, projects, and 10+ exercises per chapter? Check out SQL Server for Developers & DBAs, coming soon on this site.

  • SQL Server Trigger Best Practices: Why Triggers Are Invisible, and When to Avoid Them

    SQL Server Trigger Best Practices: Why Triggers Are Invisible, and When to Avoid Them

    Triggers are powerful precisely because they’re automatic — which is also exactly why they need to be used deliberately, not as a default habit. This closing lesson of the chapter is about judgment: having built both DML and DDL/logon triggers, here’s when that power is worth the tradeoff and when it isn’t.

    The Trigger You Can’t See(same UPDATE, a hidden consequence below)▲ visible▼ invisibleUPDATE AccountSET balance = 5500!TRIGGER firesno line of app code showsthis is happening at allAccountAudit row writtenlegitimate use — guaranteed…which can fire ANOTHER triggerindirect cycle — not blocked byRECURSIVE_TRIGGERS OFFsys.triggersthe only way to see itwithout reading app codeGotcha: RECURSIVE_TRIGGERS OFF blocks direct self-fire only —an indirect two-table loop can still run forever. 📌

    Four Real Pitfalls

    Pitfall Fix
    Assuming single-row operation Always write set-based logic joining to inserted/deleted (the exact bug proven in Lesson 1)
    Recursive triggers firing themselves Know your nested/recursive triggers DB settings; guard with logic to detect and skip re-entry
    Hidden performance cost Document clearly; keep triggers fast; avoid heavy logic on hot-path, high-write tables
    Multiple triggers, no guaranteed order Prefer one trigger per table/event; use sp_settriggerorder if unavoidable

    Recursive Triggers, Concretely

    -- A trigger on Account that updates Account itself can re-fire the same trigger,
    -- if RECURSIVE_TRIGGERS is on for the database (off by default):
    ALTER DATABASE CURRENT SET RECURSIVE_TRIGGERS OFF; -- the safe default
    
    -- Even with recursion off, an INDIRECT loop is still possible:
    -- trigger on Account updates Order → trigger on Order updates Account → fires the first trigger again
    -- RECURSIVE_TRIGGERS OFF only blocks DIRECT self-triggering, not this indirect cycle
    Common mistake: Assuming RECURSIVE_TRIGGERS OFF (the default) makes trigger loops impossible. It only prevents a trigger from directly re-firing itself — an indirect cycle through a second table’s trigger is still entirely possible and won’t be caught by this setting.

    The Biggest Philosophical Pitfall

    UPDATE Account SET balance = 5500 Looks like a simple update… no visible sign a trigger will fire EXEC usp_UpdateBalance … An explicit, visible decision to run specific logic

    A developer reading application code that runs a plain UPDATE has no way to know a trigger will also fire, unless they separately go check the database schema. A stored procedure call, by contrast, is a visible, greppable, explicit decision to invoke specific logic — anyone reading the call site immediately knows exactly what runs. This invisibility is triggers’ single biggest real-world cost, independent of performance.

    -- A genuinely good way to discover what triggers exist on a table you've inherited:
    SELECT name, is_disabled, OBJECT_DEFINITION(object_id) AS definition
    FROM sys.triggers WHERE parent_id = OBJECT_ID('dbo.Account');

    When Triggers Are Still the Right Call

    Use triggers when you genuinely need guaranteed enforcement regardless of write path — auditing (Lesson 2’s DDL example), cross-table integrity that CHECK constraints can’t express (Chapter 5), or a rule that must apply even to ad-hoc scripts run directly by a DBA, bypassing any application or stored procedure entirely. Avoid them for things a stored procedure or application layer could handle just as reliably, and far more visibly, since “guaranteed no matter what” is the specific property that justifies accepting the invisibility tradeoff — don’t pay that cost for a rule nothing will ever actually bypass.

    Practice tip: Run the sys.triggers query above against any table you’ve built triggers on across this chapter, and read back the definitions via OBJECT_DEFINITION. Getting comfortable discovering triggers this way is a genuinely useful skill for working with a database you didn’t design yourself.

    Enjoyed this?

    Subscribe to get every new SQL Server lesson as soon as it’s published, and share it with a developer who’d find it useful.

    📡 Subscribe via RSS  | 
    Share on X  | 
    Share on LinkedIn  | 
    Share on Facebook

    Want the full structured course with quizzes, projects, and 10+ exercises per chapter? Check out SQL Server for Developers & DBAs, coming soon on this site.

  • Build a Production-Style SQL Server Backend: A Capstone Ticket System Project

    Build a Production-Style SQL Server Backend: A Capstone Ticket System Project

    This combines every chapter of the advanced track into one realistic deliverable: the backend for TicketDesk, a small support-ticket system, built the way a real backend actually gets built — ambiguous edges, several defensible designs, and a requirement to justify your choices, not just produce code that runs.

    TicketDesk: everything, one schema(each requirement maps to a chapter)Customercreates a ticketusp_Ticket_Create (Ch.1, Ch.4)Ticketstatus, priorityusp_Ticket_Assign (Ch.4)Agentmust be activeTicketAuditAFTER UPDATE trigger,set-based (Ch.7)TicketCommentauthor_type, bodyvw_AgentWorkloadmind the JOIN type — 0-ticket agents must still show (Ch.6)one schema, everychapter of the course ✓the app’s service account:least-privilege, neverdb_owner (Ch.10) 📌

    Schema Requirements

    • Agent: agent_id, name, email (unique), is_active
    • Customer: customer_id, name, email (unique)
    • Ticket: ticket_id, customer_id (FK), assigned_agent_id (FK, nullable — unassigned tickets are a valid state), status, priority, created_at, resolved_at
    • TicketComment: comment_id, ticket_id (FK), author_type, body, created_at
    • TicketAudit: populated automatically by a trigger — old_status, new_status, changed_at

    The Architecture, Visualized

    Customer Ticket Agent TicketAudit TicketComment

    Business Logic Requirements — Mapped to Where You Learned Each One

    Requirement Chapter it draws on
    fn_GetOpenTicketCount(@agentId) — scalar or inline TVF, with a justification comment for which type you chose and why Ch.2
    usp_Ticket_Create — TRY/CATCH + transaction, OUTPUT parameter for the new ticket_id Ch.1, Ch.4
    usp_Ticket_Assign — THROWs if the agent is not active Ch.4
    usp_Ticket_Resolve — THROWs if the ticket is already closed Ch.4, Ch.5
    AFTER UPDATE trigger on Ticket — logs every status change to TicketAudit, correctly set-based for multi-row updates Ch.7
    vw_AgentWorkload — one row per active agent, including agents with zero open tickets (mind the JOIN type) Ch.6
    -- A skeleton for one requirement, deliberately incomplete — you decide the JOIN type
    CREATE VIEW dbo.vw_AgentWorkload AS
    SELECT a.agent_id, a.name, COUNT(t.ticket_id) AS open_ticket_count
    FROM dbo.Agent a
    -- ??? JOIN dbo.Ticket t ON t.assigned_agent_id = a.agent_id AND t.status IN ('open','in_progress')
    WHERE a.is_active = 1
    GROUP BY a.agent_id, a.name;

    The blank above is deliberate: pick the wrong JOIN type here and agents with zero open tickets silently vanish from the report — the exact LEFT JOIN + WHERE-vs-ON distinction from the Fundamentals course, now applied inside a view that a real dashboard would depend on.

    Performance & Security Requirements

    • Populate Ticket with 5,000+ rows and design a covering/filtered index for “open tickets by agent, ordered by priority” — prove it with before/after STATISTICS IO (Ch.8)
    • Create a least-privilege service account for the application — not db_owner, with explicit GRANTs you can justify one by one (Ch.10)

    Self-Check Before You Consider It Done

    Check Why it matters
    Run a multi-row UPDATE against Ticket’s status column and confirm every changed row appears in TicketAudit Catches the single-row-assumption trigger bug from Ch.7
    Call usp_Ticket_Assign against an inactive agent Confirms your THROW logic actually fires, not just compiles
    Query vw_AgentWorkload and confirm an agent with zero tickets still appears, with count 0 Confirms the correct JOIN type from the skeleton above
    Log in as your least-privilege service account and confirm it genuinely cannot do more than granted The only real proof least-privilege was actually applied, not just declared

    Why This Is the Right Capstone

    Every chapter of this track shows up here: functions, procedures with proper error handling, a correctly set-based trigger, a view with the right JOIN type, indexing backed by real measurement, and least-privilege security. It mirrors how a real backend ticket actually gets built — ambiguous edges, multiple valid designs, and a requirement to justify your decisions, not just produce working code.

    What comes next: Combined with the Fundamentals capstone, you now have two complete, defensible schemas behind you — a good portfolio starting point. The Performance Tuning course picks up exactly where this leaves off: given a schema like TicketDesk under real load, how do you diagnose and fix what’s actually slow, using evidence rather than guesswork.

    Enjoyed this?

    Subscribe to get every new SQL Server lesson as soon as it’s published, and share it with a developer who’d find it useful.

    📡 Subscribe via RSS  | 
    Share on X  | 
    Share on LinkedIn  | 
    Share on Facebook

    You’ve completed the full curriculum! Check out SQL Server Fundamentals and SQL Server for Developers & DBAs, coming soon as structured courses on this site.