Category: SQL Server Advanced

Advanced T-SQL, stored procedures, performance tuning, and interview prep for SQL Server developers and DBAs.

  • Top SQL Server Interview Questions and Answers, by Topic

    Top SQL Server Interview Questions and Answers, by Topic

    Not new material — every answer below traces back to a specific earlier lesson in this course, cited so you can go re-derive the full reasoning if a follow-up question digs deeper than the one-liner. A reference bank organized the way it actually gets asked in an interview room.

    Interview Q → A, by topic(the reasoning behind the one-liner)Q: DELETE vs TRUNCATEvs DROP?(the classic opener)flip →A: logged row deletes,page deallocation,or structure gone entirely —each fires (or skips) triggers differentlyevery card is tagged back to its chapter:FundamentalsJoins & SetsFunctions & ProcsPerformanceTransactions & Securityevery answer traces back toa chapter — go re-derive itsay WHY, not just WHAT —interviewers probe pastthe one-line definition 📌

    Fundamentals

    Q: What’s the difference between DELETE, TRUNCATE, and DROP?
    A: DELETE removes rows (optionally filtered with WHERE), is logged row-by-row, fires DELETE triggers, and can be rolled back mid-transaction. TRUNCATE removes all rows, deallocates pages directly, resets IDENTITY, doesn’t fire triggers, and can’t be filtered. DROP removes the entire table structure and data permanently. (Fundamentals Ch.2)
    Q: What’s the difference between WHERE and HAVING?
    A: WHERE filters rows before grouping; HAVING filters groups after GROUP BY — HAVING can reference aggregates, WHERE cannot, because at the point WHERE runs, no aggregate has been computed yet. (Fundamentals Ch.4)
    Q: Why does WHERE column = NULL always return zero rows?
    A: SQL uses three-valued logic — NULL means “unknown,” and unknown = unknown evaluates to unknown, not true. Use IS NULL instead. (Fundamentals Ch.3)

    Joins & Sets

    Q: When does a LEFT JOIN silently behave like an INNER JOIN?
    A: When a filter on the right table’s column sits in WHERE instead of ON — NULL fails most WHERE comparisons, discarding the unmatched left rows LEFT JOIN was meant to preserve. (Fundamentals Ch.5)
    Q: What’s the difference between UNION and UNION ALL?
    A: UNION removes duplicate rows across the combined result (a real cost); UNION ALL keeps every row including duplicates and is faster since it skips the dedup pass. (Fundamentals Ch.5)

    Functions & Procedures

    Q: When would you choose a stored procedure over a function?
    A: When you need to modify data, manage explicit transactions, use TRY/CATCH, or return multiple result sets — none of which a function can do, by design (attempting DML inside a function throws “invalid use of a side-effecting operator”). (Ch.2, Ch.4)
    Q: What’s the difference between a temp table and a table variable?
    A: The behavioral difference that actually matters: a table variable’s contents survive a transaction ROLLBACK; a temp table’s contents are rolled back with the transaction. Table variables also historically carry weaker optimizer statistics. (Ch.3)

    Performance

    Q: What’s the difference between a clustered and nonclustered index?
    A: A clustered index’s leaf level IS the actual data, physically ordered by the key — at most one per table. A nonclustered index’s leaf holds the key plus a pointer back to the clustered index, requiring a key lookup for any additional columns not covered. (Ch.8)
    Q: How would you diagnose a slow query in production?
    A: Check sys.dm_exec_query_stats for cost, capture the actual execution plan, look for Table Scans/Key Lookups and Estimated-vs-Actual gaps, confirm with STATISTICS IO, then design a targeted (ideally covering) index and re-measure. (Ch.8)
    Q: What is parameter sniffing?
    A: A stored procedure’s execution plan is compiled once and cached based on the first parameter value seen; that plan gets reused for every later call regardless of whether the shape of the data matches, sometimes producing a fast plan for one caller and a terrible one for another. (Ch.4)

    Transactions & Security

    Q: What causes a deadlock, and how do you prevent one?
    A: Two transactions each holding a lock the other needs, in a circular wait. Prevent by always acquiring locks on shared resources in the same order across the entire application — a code-level fix, not a database configuration one. (Ch.9)
    Q: What’s the difference between TDE and Always Encrypted?
    A: TDE protects data at rest (stolen files/backups) — an authorized query still sees plaintext. Always Encrypted keeps the server from ever seeing plaintext at all; decryption happens client-side, protecting the data even from a DBA with full query access. (Ch.10)
    Q: Why shouldn’t an application’s service account be db_owner?
    A: Least privilege — a compromised connection with db_owner can drop every table and read every row; the same compromise with a narrowly-scoped role can only do what that role explicitly permits. (Ch.10)

    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.

  • 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.

  • How to Read a SQL Server Execution Plan: Seeks, Scans, and Key Lookups

    How to Read a SQL Server Execution Plan: Seeks, Scans, and Key Lookups

    The last two lessons referenced execution plans repeatedly to prove their claims. Here’s how to actually read one systematically — the skill that turns “this query feels slow” into “this specific operator is the problem, for this specific reason.”

    Reading an execution plan(right to left, top to bottom)data flows this direction as you read →Index Seekcustomer_id = 42① runs first — good!Key Lookupjumps back, per row!② the expensive partSELECT resultrows you see③ you read this lastthe #1 diagnostic signal — estimated vs actual rows:Estimated: 12Actual: 45,000huge gap = stale statsor a bad estimate! ⚠cost % is built onthe SAME estimate —a 5% operator can bethe real bottleneck 📌

    In SSMS, press Ctrl+M (Include Actual Execution Plan) before running a query.

    SET STATISTICS IO ON;
    SET STATISTICS TIME ON;
    
    SELECT customer_id, order_status, order_total
    FROM dbo.OrderLog
    WHERE customer_id = 42;

    The Key Plan Elements

    Plan element What it means
    Index Seek Good — navigated the B-tree directly to matching rows, exactly the root→branch→leaf path from Lesson 1
    Index Scan Read the entire index — fine on small tables, a red flag on huge ones for selective queries
    Table Scan No usable index existed at all — the engine has no B-tree to navigate
    Key Lookup Jump back to the clustered index per row — the exact problem Lesson 2’s covering index (INCLUDE) fixes

    Read a plan right to left, top to bottom — the rightmost, deepest operators run first (typically the actual table/index access), feeding data up and left into operators that filter, join, and aggregate it, until the leftmost operator produces the final result.

    The Single Most Useful Diagnostic Signal

    A large gap between Estimated and Actual rows means stale statistics, or a shape defeating good estimation

    The optimizer chooses its plan based on estimated row counts — when those estimates are badly wrong, it often picks a suboptimal plan (the wrong join type, an index skipped in favor of a scan, an inappropriate memory grant). STATISTICS IO reports logical reads per table, often a more stable, comparable metric across runs than wall-clock time, since wall-clock time is affected by whatever else the machine happens to be doing at that moment.

    The Cost Percentage Trap

    Common mistake: Treating an operator’s cost percentage (the big bold number SSMS shows under each operator) as a reliable measure of real-world expense. It’s derived from the same potentially-wrong estimates driving the whole plan — an operator estimated at 5% of a query’s cost can be the actual bottleneck if its underlying row estimate was badly off. Cross-check cost percentage against actual row counts (visible by hovering over each operator) before trusting it.
    -- A parameter-sniffing-prone query worth trying: run once with a common value,
    -- once with a rare one, and compare estimated vs actual rows on the same plan shape
    SELECT * FROM dbo.OrderLog WHERE order_status = 'completed'; -- common
    SELECT * FROM dbo.OrderLog WHERE order_status = 'cancelled'; -- rare
    -- Different row counts naturally produce different (correct) estimates per query --
    -- this becomes a real problem specifically inside a cached stored procedure plan,
    -- covered in the Performance Tuning course
    Practice tip: Run a query you already know is slow (or deliberately write one against a large table with no useful index), capture its actual execution plan, and walk it right to left identifying every Scan, Seek, and Lookup by name before looking at cost percentages at all. Building that habit — identify operators first, judge cost second — avoids the trap above.

    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 Scenario-Based Interview Questions: Find the 2nd Highest Salary, and More

    SQL Server Scenario-Based Interview Questions: Find the 2nd Highest Salary, and More

    Modern interviews increasingly favor “solve this problem” over “define this term.” Here’s how to actually handle the classics — not just the working query, but the reasoning an interviewer is actually listening for.

    The 2nd-highest-salary trap(same data, two different answers)Alex 100kSam 100kJordan 95kPriya 90knaive MAX-WHERE says THIS ✗DENSE_RANK correctly says THIS ✓the same tie-handling matters for dedupe:rn=1 — Sam, Eng — KEEPrn=2 — Sam, Eng — DELETErn=3 — Sam, Eng — DELETEties are the whole test —ROW_NUMBER breaks them, DENSE_RANK doesn’tadd a 4th row and re-run — that’s how youactually prove which version is right 📌

    Find the Second-Highest Salary (Correctly, With Ties)

    -- Robust version using DENSE_RANK, correctly handles ties at the top
    WITH Ranked AS (
        SELECT *, DENSE_RANK() OVER (ORDER BY salary DESC) AS rnk FROM dbo.Salary
    )
    SELECT * FROM Ranked WHERE rnk = 2;

    The common wrong answer (MAX(salary) WHERE salary < MAX(salary)) often works by luck, but most candidates can’t explain why it breaks down when the top salary is tied across multiple people — in that case, the “wrong” version silently returns the third-highest distinct salary, not the second, because two people share first place. DENSE_RANK (Ch.6) makes the tie-handling explicit and correct by definition, not by accident.

    -- Prove the difference yourself: with a tie at the top, these give different answers
    INSERT INTO dbo.Salary (name, salary) VALUES ('Alex', 100000), ('Sam', 100000), ('Priya', 90000);
    SELECT MAX(salary) FROM dbo.Salary WHERE salary < (SELECT MAX(salary) FROM dbo.Salary); -- 90000, correct here by luck
    -- Add a 4th row: ('Jordan', 95000) and re-run — now compare against the DENSE_RANK version

    Find Duplicate Rows

    SELECT name, department, COUNT(*) AS occurrences
    FROM dbo.Salary
    GROUP BY name, department
    HAVING COUNT(*) > 1;

    This is Fundamentals Ch.4's GROUP BY + HAVING pattern applied directly — "duplicates" is really just "groups with more than one member," the same shape as every other GROUP BY/HAVING question, just with a different threshold.

    Delete Duplicates, Keeping One Copy

    WITH Deduped AS (
        SELECT *, ROW_NUMBER() OVER (PARTITION BY name, department ORDER BY employee_id) AS rn
        FROM dbo.Salary
    )
    DELETE FROM Deduped WHERE rn > 1;

    A genuinely common follow-up to the duplicate-finding question above, and a real test of whether you understand ROW_NUMBER's uniqueness-guarantee well enough to use it for a DELETE, not just a SELECT — note this is deleting through the CTE, a pattern worth having ready.

    "How Would You Diagnose a Slow Query?" — The Strong Answer Structure

    Confirm where time goes Capture execution plan Check DMVs for waits Test a hypothesis

    Interviewers evaluate the process, not just the final answer — narrate your reasoning out loud, in this order (which is precisely the Chapter 8 DMV lesson's toolkit, applied as a live workflow), rather than jumping straight to "add an index." A candidate who says "I'd add an index" with no diagnostic step first reads as guessing; one who walks through sys.dm_exec_query_stats → execution plan → sys.dm_os_wait_stats → a specific, testable fix reads as someone who's actually done this under pressure before.

    Practice tip: Pick any two questions from this lesson and Lesson 1 combined, and answer them out loud, to another person or recorded, within 90 seconds each — the real interview constraint isn't knowing the answer, it's producing a clear, well-structured explanation of it under mild time pressure. That's a different skill from recognizing the right answer on a page, and it's worth practicing separately.

    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.

  • Diagnosing Blocking in SQL Server: Finding Who’s Blocking Whom

    Diagnosing Blocking in SQL Server: Finding Who’s Blocking Whom

    A deadlock (previous lesson) is SQL Server actively resolving an impossible situation by killing one transaction. Ordinary blocking is different and far more common: one session simply waiting its turn for a lock another session holds, which resolves on its own once the first session finishes — no error, no victim, just a delay. Not every case of one session waiting on another is a bug; moderate, short-lived blocking is normal under real concurrent load. Here’s how to tell when it’s actually a problem.

    Who’s blocking whom?(one session waiting on another’s lock)Session 61BLOCKED — waitingwait_type: LCK_M_Ublocking_session_id = 55Session 55HOLDS the lockstill running…the usual root cause — a transaction held open too long:BEGIN TRAN…calls a payment API, waits on network…COMMITlocks held this whole time —everyone else just queues up 😩short blocking under loadis normal — the fix is aSHORTER window, not more RAM 📌

    Finding the Blocker

    SELECT
        blocking.session_id AS blocking_session,
        blocked.session_id AS blocked_session,
        blocked.wait_type,
        blocked.wait_time,
        blocked_text.text AS blocked_query
    FROM sys.dm_exec_requests blocked
    JOIN sys.dm_exec_sessions blocking ON blocking.session_id = blocked.blocking_session_id
    CROSS APPLY sys.dm_exec_sql_text(blocked.sql_handle) blocked_text
    WHERE blocked.blocking_session_id <> 0;

    This is the same blocking_session_id column flagged as the single most actionable field in Chapter 8’s DMV lesson — this query is that pointer, fully realized into a real diagnostic report.

    The Real Root Cause, Most of the Time

    A transaction held open far longer than necessary (e.g. waiting on user input mid-transaction)

    It becomes a genuine problem when a transaction holds locks far longer than necessary. The fix is almost always “keep transactions as short as possible” — not “add more indexes” or “increase timeout,” which just makes users wait longer for a symptom instead of fixing the actual cause.

    -- The specific anti-pattern that causes most real-world blocking incidents:
    BEGIN TRANSACTION;
    UPDATE dbo.Order SET status = 'processing' WHERE order_id = 500;
    -- ...application code here calls an external API, waits on a user click,
    -- or does anything else slow, all while the transaction (and its locks) stays open...
    COMMIT TRANSACTION; -- doesn't happen until that slow thing finishes
    
    -- The fix: do all slow, non-database work BEFORE or AFTER the transaction,
    -- never DURING it. Keep the window between BEGIN and COMMIT as short as possible.
    Common mistake: Opening a transaction, then calling out to an external service (payment gateway, email API, another microservice) before committing. Any latency or hang in that external call directly extends how long your locks are held, potentially blocking every other session that needs the same rows — this single anti-pattern is behind a large share of real production blocking incidents.

    Chapter 9, End to End

    These three lessons form one continuous story: ACID (Lesson 1) is the guarantee; isolation levels (Lesson 2) are the tunable dial controlling how strictly “Isolation” is enforced, with deadlocks as the sharp edge of getting concurrent access patterns wrong; and blocking (this lesson) is the everyday, non-error version of the same underlying mechanism — locks doing their job, just visible when they hold longer than expected.

    Practice tip: Open a transaction, run an UPDATE, and deliberately leave it open (don’t COMMIT or ROLLBACK yet) in one query window. In a second window, run the blocking-detection query above and confirm you can see your own first session listed as the blocker. This hands-on confirmation is exactly the diagnostic workflow you’d use on a real, unfamiliar production incident.

    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.

  • Clustered vs Nonclustered Indexes in SQL Server: The B-Tree Explained

    Clustered vs Nonclustered Indexes in SQL Server: The B-Tree Explained

    Every index recommendation you’ve absorbed passively up to this point (“add an index here”) gets its real mechanical foundation in this chapter. Every SQL Server index is a B-tree: a root page, branch pages, and leaf pages. The difference between clustered and nonclustered is what lives at the leaf level — and that single difference explains almost everything else in this chapter.

    Clustered = The Table Itself(nonclustered is a separate, narrow lookup)CLUSTERED INDEXleaf level = the data itselfid 101 — data rowid 102 — data rowid 103 — data rowid 104 — data rowid 105 — data rowphysically stored in this exact orderat most ONE per tableNONCLUSTERED INDEXleaf level = key + pointer onlystatus=’shipped’ → id 101status=’cancelled’ → id 104status=’shipped’ → id 103MANY nonclustered allowed per tablekey lookup →Gotcha: many key lookups can cost more than one full scan —that’s exactly when the optimizer abandons the index. 📌

    The B-Tree, Visualized

    Root: 1-50000 Branch: 1-16666 Branch: 16667-33333 Branch: 33334-50000 Leaf pages — actual data rows, in key order

    A seek walks root → branch → leaf, typically just 3-4 page reads even against a table with millions of rows — this is the entire reason indexes matter: it turns “read every row” into “read a handful of pages,” a logarithmic rather than linear cost as the table grows.

    Clustered vs Nonclustered

    Clustered Nonclustered
    Leaf level contains The actual data rows Key + pointer back to clustered key
    Per table At most one Many allowed
    Created by default via PRIMARY KEY (Fundamentals Ch.5) Nothing — explicit CREATE INDEX

    This is worth internalizing precisely: a clustered index doesn’t sit “alongside” the table — for a clustered table, the table is the index. There’s no separate copy of the data; the rows are physically stored in clustered-key order. A nonclustered index, by contrast, is a genuinely separate structure, small and narrow, that only stores its key columns plus a pointer back.

    The Key Lookup Problem

    A query that filters on a nonclustered index’s column but selects other columns not in that index requires a key lookup — jumping from the nonclustered leaf back to the clustered index to fetch the rest. For a handful of rows this is cheap; for a large result set, SQL Server often abandons the index entirely and scans the whole table, because thousands of individual lookups cost more than one sequential scan.

    -- Confirm this tipping-point behavior yourself
    CREATE NONCLUSTERED INDEX IX_OrderLog_Status ON dbo.OrderLog (order_status);
    
    -- Selective (few matching rows): optimizer uses the index + key lookups
    SELECT * FROM dbo.OrderLog WHERE order_status = 'cancelled'; -- rare status, few rows
    
    -- Unselective (most rows match): optimizer likely abandons the index for a scan
    SELECT * FROM dbo.OrderLog WHERE order_status = 'completed'; -- common status, most rows
    -- Compare the two actual execution plans (Ctrl+M) to see the optimizer's choice change
    Practice tip: Run both queries above with the actual execution plan visible, and hover over each Seek/Scan operator to read its estimated row count and cost percentage. Seeing the optimizer switch strategies based purely on how selective the filter is — not on anything about the index itself — is the single most useful intuition this lesson can give you before Chapter 3’s execution plan reading lesson goes deeper.

    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.

  • Window Functions in SQL Server: ROW_NUMBER, RANK, DENSE_RANK, and Running Totals

    Window Functions in SQL Server: ROW_NUMBER, RANK, DENSE_RANK, and Running Totals

    The single most important thing to understand about window functions: unlike GROUP BY (Fundamentals Chapter 4), every original row stays in the result, enriched with a calculated value alongside it, rather than collapsed into one row per group. This is the tool for “rank each row within its group” or “running total as of this row” — questions GROUP BY structurally cannot answer, since it always reduces row count.

    Window Functions: Every Row Stays(unlike GROUP BY, which collapses rows)FRAME (current frame)$100$250$400$150$300row 1row 2row 3 (current)row 4 (not yet)row 5 (not yet)running total — SUM() OVER (… UNBOUNDED PRECEDING)1003507509001200

    Ranking Within Groups

    SELECT salesperson, region, amount, sale_date,
        ROW_NUMBER() OVER (PARTITION BY region ORDER BY amount DESC) AS row_num,
        RANK()       OVER (PARTITION BY region ORDER BY amount DESC) AS rank_num,
        DENSE_RANK() OVER (PARTITION BY region ORDER BY amount DESC) AS dense_rank_num
    FROM dbo.Sale;

    PARTITION BY is doing the conceptual work GROUP BY would do — splitting rows into groups — but instead of collapsing each group into one row, it just resets the ranking/calculation at each group boundary while keeping every row visible.

    Running Totals

    SELECT salesperson, region, sale_date, amount,
        SUM(amount) OVER (PARTITION BY region ORDER BY sale_date
                           ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total
    FROM dbo.Sale;

    The ROWS BETWEEN ... AND CURRENT ROW clause is called a frame — it defines exactly which rows, relative to the current one, get included in the calculation. “Unbounded preceding to current row” means “every row from the start of this partition up through this one,” which is precisely what a running total means.

    -- A different frame answers a different question: a 3-row moving average
    SELECT salesperson, region, sale_date, amount,
        AVG(amount) OVER (PARTITION BY region ORDER BY sale_date
                           ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS moving_avg_3
    FROM dbo.Sale;

    How Ties Are Handled — The Difference That Actually Matters

    Function Behavior on ties
    ROW_NUMBER() Always unique, arbitrarily breaks ties (1,2,3,4…)
    RANK() Ties share a rank, next rank skips (1,1,3,4…)
    DENSE_RANK() Ties share a rank, next rank doesn’t skip (1,1,2,3…)
    Common mistake: Using ROW_NUMBER() to find “the top 3 salespeople,” which silently discards a genuine 3-way tie for 3rd place down to one arbitrary row. If ties should all be included, RANK() <= 3 is the correct tool — it can return more than 3 rows when there’s a tie at the boundary, which is usually exactly what “top 3” should mean in a real report.

    Comparing to the Previous Row: LAG and LEAD

    SELECT salesperson, sale_date, amount,
        LAG(amount) OVER (PARTITION BY salesperson ORDER BY sale_date) AS previous_sale,
        amount - LAG(amount) OVER (PARTITION BY salesperson ORDER BY sale_date) AS change_from_last
    FROM dbo.Sale;

    LAG reaches backward, LEAD reaches forward — both eliminate what used to require an awkward self-join to compare a row against its neighbor, exactly the SELF JOIN pattern from Fundamentals Chapter 5, now solved far more cleanly.

    GROUP BY vs Window Functions

    GROUP BY Fewer rows — one per group Window Function Every row kept, enriched

    Practice tip: Write a query answering “for each sale, show what percentage it represents of its region’s total” — this needs both a window SUM (the region total, spread across every row) and simple division against each row’s own amount. It’s a genuinely common real-world request that GROUP BY alone cannot answer in a single query.

    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.