Category: SQL Server Advanced

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

  • ON DELETE CASCADE in SQL Server: Powerful, and Genuinely Dangerous If Misused

    ON DELETE CASCADE in SQL Server: Powerful, and Genuinely Dangerous If Misused

    Foreign keys (Fundamentals Chapter 5) can do more than just block invalid deletes — they can propagate changes automatically. That power cuts both ways, and this is one of the few topics in this course where the “safe default” genuinely differs from the option that looks most convenient in a demo.

    Cascade: One Push, Many FallsDELETE Category WHERE id=1Categoryid=1 deletedON DELETE CASCADE →ProductFK CASCADEON DELETE CASCADE →ReviewFK CASCADEGotcha: one DELETE can silently wipe out far more thanintended — always COUNT(*) the blast radius first. 📌

    The Four Options

    CREATE TABLE dbo.Category2 (
        category_id INT IDENTITY(1,1) PRIMARY KEY,
        name        NVARCHAR(50) NOT NULL
    );
    
    CREATE TABLE dbo.Product2 (
        product_id  INT IDENTITY(1,1) PRIMARY KEY,
        category_id INT NOT NULL
            REFERENCES dbo.Category2(category_id) ON DELETE CASCADE ON UPDATE CASCADE,
        name        NVARCHAR(100) NOT NULL
    );
    Option Behavior when parent row is deleted
    NO ACTION (default) Blocks the delete if child rows reference it — exactly the Chapter 5 (Fundamentals) behavior you’ve already seen
    CASCADE Automatically deletes matching child rows too
    SET NULL Sets the child’s FK column to NULL (requires the FK column to allow NULL)
    SET DEFAULT Sets the child’s FK column to its DEFAULT value (requires one to be defined)

    The Real Risk

    CASCADE on a deep relationship chain can silently wipe out far more data than intended from one top-level delete

    Seeing this concretely: if Category → Product is CASCADE, and Product → Review is also CASCADE, then one DELETE FROM Category2 WHERE category_id = 1 can silently remove every product in that category and every review those products ever received — all from a single statement, with no confirmation, no listing of what’s about to disappear. Multi-level CASCADE chains are exactly how a well-intentioned “clean up an old category” operation becomes an incident.

    -- Prove the blast radius yourself before trusting a cascade chain in production:
    SELECT COUNT(*) FROM dbo.Product2 WHERE category_id = 1; -- how many products?
    -- If Review also cascades from Product, this number matters too:
    -- SELECT COUNT(*) FROM dbo.Review WHERE product_id IN (SELECT product_id FROM dbo.Product2 WHERE category_id = 1);

    A Concrete Decision Rule

    CASCADE is right for genuinely dependent data — data with no meaning or value once its parent is gone (delete an Order, its OrderItems should go too; nobody wants orphaned line items pointing at a deleted order). It’s the wrong choice for anything with independent value — don’t cascade-delete Product just because a Category is removed; the products still exist and are still sellable, they just need a new category. Usually you want NO ACTION there, forcing an explicit, visible decision (reassign the products, or delete them deliberately) instead of an invisible side effect.

    SQL Server also blocks a specific dangerous configuration outright: you cannot create multiple CASCADE paths that could reach the same table through different routes, since it can’t guarantee a deterministic order of operations — a useful, hardcoded safety net worth knowing about rather than fighting.

    Practice tip: Before adding ON DELETE CASCADE to any real foreign key, write the SELECT COUNT(*) query that shows exactly what a worst-case delete would remove, and actually run it against realistic data volume. If that number would alarm you in production, NO ACTION is very likely the correct default, not CASCADE.

    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 Views Explained: Standard, Indexed, and Updatable Views

    SQL Server Views Explained: Standard, Indexed, and Updatable Views

    Chapter 1 (Fundamentals) previewed views as a saved, reusable SELECT. SQL Server actually offers three genuinely different flavors, each with different rules — and one of them (indexed views) is a real physical storage decision, not just a query-organization convenience.

    A View Is a Window, Not a Copydbo.Sale (the real table)VIEWalways shows live data underneathStandard Viewalways live, zero storageUpdatablesingle table only, writes pass throughIndexed (Materialized)physically stored — not live

    Standard View

    CREATE VIEW dbo.vw_WestRegionSales AS
    SELECT sale_id, salesperson, amount, sale_date FROM dbo.Sale WHERE region = 'West';
    GO
    
    SELECT * FROM dbo.vw_WestRegionSales WHERE amount > 3000;

    Always reflects live data — it’s just a saved query, re-executed each time it’s referenced. A standard view has zero storage cost of its own; think of it as a named, reusable shortcut for a SELECT, nothing more.

    Updatable Views

    UPDATE dbo.vw_WestRegionSales SET amount = 4300 WHERE sale_id = 1;

    A simple, single-table view can accept INSERT/UPDATE directly — SQL Server translates the update against the view back into an update against the underlying Sale table automatically. But it becomes non-updatable the moment it involves a JOIN, GROUP BY/aggregate, DISTINCT, or UNION. SQL Server can no longer unambiguously map an update back to a single row in a single base table.

    -- Confirm this yourself: a JOIN-based view rejects UPDATE outright
    CREATE VIEW dbo.vw_SaleWithRegionName AS
    SELECT s.sale_id, s.amount, r.region_name FROM dbo.Sale s JOIN dbo.Region r ON r.region_id = s.region_id;
    GO
    UPDATE dbo.vw_SaleWithRegionName SET amount = 5000 WHERE sale_id = 1;
    -- Msg 4405: View or function ... is not updatable because the modification affects
    -- multiple base tables.

    Indexed (Materialized) Views

    CREATE VIEW dbo.vw_RegionTotals
    WITH SCHEMABINDING
    AS
    SELECT region, SUM(amount) AS total_sales, COUNT_BIG(*) AS sale_count
    FROM dbo.Sale
    GROUP BY region;
    GO
    
    CREATE UNIQUE CLUSTERED INDEX IX_RegionTotals ON dbo.vw_RegionTotals (region);

    WITH SCHEMABINDING locks the view’s dependency on the base table’s schema — required before you can index a view

    Once indexed, SQL Server physically stores and maintains the aggregate automatically as data changes — genuinely useful for expensive aggregates queried extremely often (a dashboard hit by hundreds of requests per minute), at the cost of slightly slower writes to the base table, since every INSERT/UPDATE/DELETE to Sale now also has to update the materialized total. This is a real space-vs-time tradeoff, the same category of decision you’ll formalize fully once indexing is covered in Chapter 8.

    Common mistake: Adding WITH SCHEMABINDING to a view and then being surprised you can no longer ALTER TABLE ... DROP COLUMN on a column the view references, or drop the base table at all, without first dropping the view. Schema binding is a real, enforced dependency — not just documentation.
    Practice tip: Build vw_RegionTotals exactly as shown, insert a new Sale row, and re-query the view immediately — confirm the total updates without you doing anything extra. That automatic maintenance, seen firsthand, is the entire value proposition of an indexed view.

    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.

  • Common Table Expressions (CTEs) and Recursive CTEs in SQL Server Explained

    Common Table Expressions (CTEs) and Recursive CTEs in SQL Server Explained

    A CTE is a named subquery that makes complex queries genuinely readable — and recursive CTEs solve a problem plain SQL can’t touch at all: hierarchies of unknown, variable depth.

    Recursive CTE: Climbing the Tree★ ANCHOR: level 0Priya (CEO)manager_id = NULLMiguellevel 1Aishalevel 1Liamlevel 2JOIN back to CTE,level + 1 each passGotcha: a bad circular manager_id would recurse forever —MAXRECURSION 100 (the default) stops it automatically. 📌

    A Basic CTE

    WITH RegionSummary AS (
        SELECT region, SUM(amount) AS total_sales
        FROM dbo.Sale
        GROUP BY region
    )
    SELECT region, total_sales FROM RegionSummary WHERE total_sales > 10000;

    Notice this achieves what Chapter 4’s Fundamentals lesson said WHERE can’t do directly — filter on an aggregate — without needing HAVING, because the aggregate is computed inside the CTE first, then the outer query treats total_sales as an ordinary column. A CTE is purely an organizational tool for readability; this exact query could be written as a subquery in FROM instead, with identical results.

    Recursive CTE: Walking a Hierarchy

    CREATE TABLE dbo.OrgChart (
        employee_id INT PRIMARY KEY,
        name        NVARCHAR(50) NOT NULL,
        manager_id  INT NULL REFERENCES dbo.OrgChart(employee_id)
    );
    INSERT INTO dbo.OrgChart VALUES (1,'Priya (CEO)',NULL), (2,'Miguel',1), (3,'Aisha',1), (4,'Liam',2);
    
    WITH OrgHierarchy AS (
        -- Anchor: top of the hierarchy
        SELECT employee_id, name, manager_id, 0 AS level
        FROM dbo.OrgChart WHERE manager_id IS NULL
    
        UNION ALL
    
        -- Recursive: joins back to the CTE itself, one level deeper each pass
        SELECT o.employee_id, o.name, o.manager_id, oh.level + 1
        FROM dbo.OrgChart o
        INNER JOIN OrgHierarchy oh ON o.manager_id = oh.employee_id
    )
    SELECT REPLICATE('  ', level) + name AS org_tree, level FROM OrgHierarchy ORDER BY level, name;

    This is a problem that genuinely cannot be solved with a fixed number of JOINs, because you don’t know upfront how many management levels deep an org chart goes — it could be 3 levels or 15, and a plain query has to be written for a specific, known number of hops. Recursion is the only tool in standard SQL that handles “unknown depth.”

    How Recursion Actually Runs

    Anchor Starting rows (level 0) Recursive member Joins to itself, +1 level each pass

    SQL Server stops automatically once a pass produces zero new rows. A CTE doesn’t persist anywhere and can’t be indexed — it only exists for the single statement it’s attached to, unlike the temp tables from Chapter 3.

    The Infinite Loop Trap

    -- A circular reference (Aisha reports to Liam, who reports to Aisha) would recurse forever
    -- without a safety net. SQL Server defaults to a hard cap of 100 recursion levels:
    SELECT * FROM dbo.OrgChart OPTION (MAXRECURSION 100); -- the implicit default
    
    -- Msg 530 fires automatically once that cap is hit on genuinely bad/circular data:
    -- "The statement terminated. The maximum recursion 100 has been exhausted..."
    
    -- You can raise or lower the cap explicitly (0 = unlimited, use with real caution)
    ;WITH OrgHierarchy AS (...)
    SELECT * FROM OrgHierarchy OPTION (MAXRECURSION 50);
    Common mistake: Assuming your hierarchy data is always clean and skipping any thought about MAXRECURSION. Real org charts, category trees, and bill-of-materials data occasionally do contain a bad circular reference from a data-entry mistake — the default 100-level cap is what turns that into a clear error instead of a runaway query.
    Practice tip: Add a fifth employee to OrgChart reporting to Liam (level 3), rerun the query, and confirm the indentation deepens correctly. Then deliberately create a circular reference (set Priya’s manager_id to Liam’s employee_id) and watch the MAXRECURSION error fire — seeing the safety net trigger for real is worth more than reading about it.

    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.

  • Constraints vs Triggers vs Procedures: Where to Enforce a SQL Server Business Rule

    Constraints vs Triggers vs Procedures: Where to Enforce a SQL Server Business Rule

    You now have four genuinely different tools capable of enforcing the same underlying rule — CHECK constraints (Fundamentals Ch.6), foreign keys, triggers (next up, Chapter 7), and stored procedures (just built). Choosing the right layer is a decision every schema designer eventually faces, and getting it wrong in either direction — too rigid or too loose — causes real, recurring pain.

    Where Should This Rule Live?(push it as close to the data as it can go)DATAcheapest, most guaranteedCHECK / FKsingle-row or existence rule(Fundamentals Ch.5-6)TRIGGERcross-row / cross-table logic(this chapter, next up)PROCEDUREmulti-step business process(sole write path required)most flexible, easiest to bypassfarther from data → more powerful, harder to guarantee

    The Options, Compared

    Mechanism Best for Limitation
    CHECK constraint Simple, single-row rules (price > 0) Cannot reference other tables or other rows
    FOREIGN KEY Referential existence rules Only “must exist,” not conditional logic
    Trigger Cross-row or cross-table logic, automatic side effects Harder to reason about, invisible to callers reading application code alone
    Stored procedure (as sole write path) Complex multi-step processes with clear business logic Only works if literally nothing else can bypass it

    The Guiding Principle

    Push a rule as close to the data as it can go without becoming unmaintainable

    A single-column rule belongs in a CHECK constraint — cheap, guaranteed, self-documenting, and enforced no matter what wrote the data (the exact reasoning from Fundamentals Chapter 6). A rule spanning multiple tables usually needs a trigger, precisely because CHECK constraints can’t reference other tables. A rule that’s genuinely a business process — multiple steps, conditional branches, needing to notify or log along the way — belongs in a stored procedure, provided your architecture guarantees that procedure is the only path data takes into the table.

    Walking a Real Rule Through All Four Layers

    Take: “a booking’s total nights can never exceed 30.” Watch how the same rule looks completely different depending on which layer enforces it:

    -- As a CHECK constraint: works ONLY if check_in/check_out are both plain columns on one row
    ALTER TABLE dbo.Booking ADD CONSTRAINT CK_MaxStay CHECK (DATEDIFF(DAY, check_in, check_out) <= 30);
    
    -- As a trigger: needed instead if the rule required looking at OTHER bookings
    -- (e.g. "no guest can have more than 30 total nights booked across all their reservations")
    CREATE TRIGGER trg_Booking_MaxTotalNights ON dbo.Booking AFTER INSERT AS
    BEGIN
        IF EXISTS (
            SELECT guest_id FROM dbo.Booking
            WHERE guest_id IN (SELECT guest_id FROM inserted)
            GROUP BY guest_id HAVING SUM(DATEDIFF(DAY, check_in, check_out)) > 30
        )
            THROW 51020, 'Guest exceeds 30 total booked nights.', 1;
    END;

    The single-row version is a CHECK constraint’s job exactly. The moment the rule needs to look across multiple rows (all of one guest’s bookings, not just the one being inserted), a CHECK constraint structurally cannot do it — that’s the trigger’s job instead. This is the actual decision criterion, not a vague sense of “complexity.”

    Practice tip: For any business rule you’re about to enforce, ask one concrete question first: “does validating this require looking at data outside the single row being changed?” If no, CHECK constraint. If yes, trigger (or procedure, if it’s really a multi-step process rather than a validation). That one question resolves the large majority of real cases.

    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.

  • Computed Columns and Multi-Column CHECK Constraints in SQL Server

    Computed Columns and Multi-Column CHECK Constraints in SQL Server

    CHECK constraints get genuinely powerful once they can reference multiple columns in the same row — exactly the boundary the previous lesson drew — and computed columns let you derive values automatically instead of trusting every INSERT/UPDATE to calculate them correctly and consistently.

    Computed Columns: Derived, Not Writtencheck_inDATE columncheck_outDATE columnnightly_rateDECIMAL, CHECK > 0nightsAS DATEDIFF(day,in,out)🔒 auto-derived, read-onlytotal_costAS nights * nightly_rate🔒 PERSISTED, indexableCHECK(check_out > check_in)

    A Table Using Both

    CREATE TABLE dbo.Booking (
        booking_id  INT IDENTITY(1,1) PRIMARY KEY,
        check_in    DATE NOT NULL,
        check_out   DATE NOT NULL,
        nightly_rate DECIMAL(8,2) NOT NULL CHECK (nightly_rate > 0),
        nights      AS DATEDIFF(DAY, check_in, check_out),
        total_cost  AS (DATEDIFF(DAY, check_in, check_out) * nightly_rate) PERSISTED,
        CONSTRAINT CK_Booking_Dates CHECK (check_out > check_in)
    );
    
    INSERT INTO dbo.Booking (check_in, check_out, nightly_rate) VALUES ('2024-06-01', '2024-06-05', 150.00);
    SELECT booking_id, nights, total_cost FROM dbo.Booking;

    Notice nights and total_cost are never written to directly — you can’t INSERT a value into them, and SQL Server rejects the attempt if you try. They’re recalculated automatically from check_in, check_out, and nightly_rate every time those source columns change, which structurally guarantees they can never drift out of sync the way a manually-maintained “total” column could.

    PERSISTED: Store It or Compute It Live?

    Not PERSISTED Recalculated on every SELECT Cannot be indexed PERSISTED Stored on disk, auto-synced Can be indexed

    Worthwhile when the column is read often relative to how often source columns change — total_cost on a booking is a good candidate, since it’s likely queried far more often than a booking’s dates ever change after creation. A table-level CHECK constraint (like CK_Booking_Dates) can reference multiple columns in the same row — just never another table or another row, which was exactly the boundary the previous lesson established.

    A Deterministic Requirement, Revisited

    -- FAILS: a computed column referencing GETDATE() cannot be PERSISTED or indexed,
    -- for the exact same non-deterministic reason a scalar function couldn't be (Chapter 2)
    ALTER TABLE dbo.Booking ADD days_until_checkin AS DATEDIFF(DAY, GETDATE(), check_in) PERSISTED;
    -- Msg 4936: Computed column 'days_until_checkin' in table 'Booking' cannot be persisted
    -- because the column is non-deterministic.
    Common mistake: Trying to PERSIST a computed column that depends on the current date/time or a random value, then being confused by the deterministic-function error. This is the same rule from Chapter 2’s scalar functions, applied to computed columns instead — a persisted value must be reproducible from its inputs alone, with nothing external sneaking in.
    Practice tip: Add a second computed column to Booking — is_long_stay AS CASE WHEN DATEDIFF(DAY, check_in, check_out) > 7 THEN 1 ELSE 0 END — and confirm you can filter on it directly in a WHERE clause, exactly like a normal column.

    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.

  • Stored Procedures vs Functions in SQL Server: When to Choose Which

    Stored Procedures vs Functions in SQL Server: When to Choose Which

    These get confused constantly, including in interviews. Here’s the clean distinction, grounded in everything you’ve built across Chapters 2 and 4 rather than as a fresh set of rules to memorize.

    Procedure vs Function(the one rule, sketched out)Need to WRITE data, orcontrol a TRANSACTION?(or return multiple result sets?)YESNOPROCEDURE✓ INSERT / UPDATE / DELETE✓ BEGIN/COMMIT/ROLLBACK, TRY/CATCH✓ can return many result sets✗ cannot be called inside SELECTFUNCTION✓ callable inside a SELECT / JOIN✓ composable, read-only building block✗ cannot write data (compile error)✗ no BEGIN/COMMIT or TRY/CATCH

    Side by Side

    Stored Procedure Function (any type)
    Modify data (INSERT/UPDATE/DELETE) Yes No — compile error if attempted
    Manage transactions (BEGIN/COMMIT/ROLLBACK) Yes No
    Callable inside a SELECT statement No Yes
    Return multiple result sets Yes No — exactly one value or one table
    Use TRY/CATCH Yes No
    Precompiled and cached like a procedure Yes Scalar/mTVF: yes; iTVF: inlines instead (Chapter 2)

    The One-Sentence Rule

    Need to write data, manage a transaction, or return multiple result sets? → Procedure. Otherwise → Function.

    Functions’ composability inside SELECT statements is their key advantage — but only for read-only, single-result-shape logic. The moment you need to write data or control a transaction explicitly, you’re in stored procedure territory, no exceptions. This isn’t a style preference; it’s enforced by the engine, as Chapter 2’s “side-effecting operator” error demonstrated directly.

    A Realistic Mixed Scenario

    Real applications typically need both, working together: an iTVF to expose a reusable, JOIN-friendly “active customers this quarter” query, and a stored procedure that uses that same iTVF internally as part of a larger workflow that also writes an audit log row and sends the result back to the caller.

    CREATE PROCEDURE dbo.usp_GenerateQuarterlyReport @quarter INT AS
    BEGIN
        SET NOCOUNT ON;
        -- Reuses an iTVF from earlier in the chapter for the read-only part
        SELECT * FROM dbo.GetEmployeesByDepartment('Sales');
        -- Then does something only a procedure can: write an audit trail
        INSERT INTO dbo.ReportLog (report_name, generated_at) VALUES ('Quarterly Sales', SYSDATETIME());
    END;

    This is the natural end state once you’ve internalized both tools: functions for the composable, read-only building blocks; procedures for orchestrating them alongside anything with a side effect.

    Practice tip: Look back at any function you wrote in Chapter 2 and ask: “if I needed this to also log who called it, could I?” The answer is no — that’s the exact moment a function needs to become, or be wrapped by, a procedure instead.

    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.

  • Error Handling and Transactions in SQL Server Stored Procedures: The Pattern to Memorize

    Error Handling and Transactions in SQL Server Stored Procedures: The Pattern to Memorize

    Production procedures need to fail safely — rolling back cleanly and surfacing a useful error, not leaving data half-changed. This lesson combines Chapter 1’s TRY/CATCH with the transaction concepts formalized fully in Chapter 9, into the single pattern you’ll reuse in nearly every write-capable procedure you ever write.

    The Pattern to Memorize(TRY/CATCH + transactions, one flow)BEGIN TRYwrap the workBEGIN TRANSACTIONone logical unitdo the workUPDATE / THROW on bad rowsCOMMITno error thrown — success pathCATCHsomething threw an errorabove — error pathIF @@TRANCOUNT>0ROLLBACK, then THROWGotcha: @@ROWCOUNT resetsafter almost EVERY statement —check it right after the statementit’s meant to describe. 📌

    The Complete Pattern

    CREATE PROCEDURE dbo.usp_UpgradeCustomerTier
        @customerId INT, @newTier NVARCHAR(20)
    AS
    BEGIN
        SET NOCOUNT ON;
        BEGIN TRY
            BEGIN TRANSACTION;
    
            UPDATE dbo.Customer SET tier = @newTier WHERE customer_id = @customerId;
    
            IF @@ROWCOUNT = 0
                THROW 51010, 'No customer found with the given ID.', 1;
    
            COMMIT TRANSACTION;
        END TRY
        BEGIN CATCH
            IF @@TRANCOUNT > 0
                ROLLBACK TRANSACTION;
    
            DECLARE @errMsg NVARCHAR(4000) = ERROR_MESSAGE();
            THROW 51011, @errMsg, 1;
        END CATCH
    END;

    @@ROWCOUNT is worth calling out on its own — it’s a system variable holding the number of rows affected by the most recent statement, and it resets after nearly every statement, including a PRINT. Check it immediately after the statement it’s meant to describe, or its value won’t mean what you think.

    Why @@TRANCOUNT Matters

    If the error happens BEFORE BEGIN TRANSACTION runs, calling ROLLBACK with no active transaction raises its own new error — check @@TRANCOUNT first

    The pattern to memorize: BEGIN TRY → BEGIN TRANSACTION → do the work → COMMIT, with a CATCH block that checks @@TRANCOUNT > 0 before rolling back, then re-throws or logs the error. This guard matters even more once procedures start calling other procedures: if this procedure was itself called from inside someone else’s already-open transaction, @@TRANCOUNT will be higher than 1, and a naive unconditional ROLLBACK here would undo work the caller is still relying on.

    Proving It Actually Rolls Back

    -- Deliberately trigger the THROW path and confirm no partial update survives
    SELECT tier FROM dbo.Customer WHERE customer_id = 99999; -- confirm this ID doesn't exist first
    EXEC dbo.usp_UpgradeCustomerTier @customerId = 99999, @newTier = 'premium';
    -- Msg 51011: No customer found with the given ID.
    SELECT * FROM dbo.Customer WHERE tier = 'premium' AND customer_id = 99999; -- confirms: nothing changed
    Practice tip: Run the failing call above yourself and confirm the error message AND the absence of any change. Then try it again with a valid customer_id and confirm the COMMIT path works. Seeing both branches fire for real is what turns “the pattern to memorize” into something you actually understand instead of copy-paste boilerplate.

    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.

  • Stored Procedure Parameters in SQL Server: Input, Output, Default, and Table-Valued

    Stored Procedure Parameters in SQL Server: Input, Output, Default, and Table-Valued

    Four parameter patterns cover almost everything you’ll need to build — from a simple optional filter to passing an entire list of values into a procedure without ever concatenating a string.

    Four ways to pass data in and outDEFAULT parameter@tier NVARCHAR(20)=’standard’omit it → default usedOUTPUT parametervalue flows caller ⇆ procneeds OUTPUT on BOTH sides ⚠️TABLE-VALUED parampass a whole list, READONLYtype-safe, multi-columnOLD WAY: CSV string‘Dana Park,Elena Petrova’breaks on embedded commasno type safety, one column onlyupgrade toNEW WAY: Table-Valued ParamCREATE TYPE … AS TABLE(…)many columns, real types, READONLYRemember: OUTPUT is required at the CALL SITE too —omit it there and SQL Server treats it as input-only.

    Default Parameters (Optional Input)

    CREATE PROCEDURE dbo.usp_CountCustomersByTier
        @tier NVARCHAR(20) = 'standard'
    AS
    BEGIN
        SET NOCOUNT ON;
        SELECT COUNT(*) AS customer_count FROM dbo.Customer WHERE tier = @tier;
    END;
    GO
    EXEC dbo.usp_CountCustomersByTier; -- uses default
    EXEC dbo.usp_CountCustomersByTier @tier = 'premium'; -- overrides it

    OUTPUT Parameters (Returning Values to the Caller)

    CREATE PROCEDURE dbo.usp_GetCustomerCount
        @tier NVARCHAR(20), @total INT OUTPUT
    AS
    BEGIN
        SET NOCOUNT ON;
        SELECT @total = COUNT(*) FROM dbo.Customer WHERE tier = @tier;
    END;
    GO
    
    DECLARE @count INT;
    EXEC dbo.usp_GetCustomerCount @tier = 'standard', @total = @count OUTPUT;
    PRINT 'Standard customers: ' + CAST(@count AS VARCHAR(10));
    Common mistake: Forgetting the OUTPUT keyword on the calling side, not just in the CREATE PROCEDURE definition. Without it at the call site too, SQL Server silently treats the parameter as input-only — your @count variable stays whatever it was before the call, with no error raised.

    Table-Valued Parameters: The Modern Way to Pass a List

    CREATE TYPE dbo.CustomerNameList AS TABLE (name NVARCHAR(100));
    GO
    
    CREATE PROCEDURE dbo.usp_GetCustomersByNames
        @Names dbo.CustomerNameList READONLY
    AS
    BEGIN
        SET NOCOUNT ON;
        SELECT c.customer_id, c.name, c.email
        FROM dbo.Customer c
        INNER JOIN @Names n ON n.name = c.name;
    END;
    GO
    
    DECLARE @list dbo.CustomerNameList;
    INSERT INTO @list VALUES ('Dana Park'), ('Elena Petrova');
    EXEC dbo.usp_GetCustomersByNames @Names = @list;

    TVPs are the correct, set-based way to pass a list into a procedure — far better than the old pattern of passing a comma-separated string and splitting it inside the procedure, which is exactly the kind of row-by-row string manipulation Chapter 1’s WHILE-loop lesson warned against. They’re always READONLY: you can read from them, never modify the caller’s table — attempting an UPDATE/DELETE/INSERT against @Names inside the procedure body is a compile error, by design.

    The Old Way, for Comparison

    -- The pre-TVP pattern (2005 and earlier, still seen in legacy code):
    CREATE PROCEDURE dbo.usp_GetCustomersByNames_Legacy @NameCsv NVARCHAR(MAX) AS
    BEGIN
        SELECT c.* FROM dbo.Customer c
        INNER JOIN STRING_SPLIT(@NameCsv, ',') s ON s.value = c.name; -- fragile: commas in names break it
    END;

    Beyond fragility with embedded delimiters, the string-splitting approach also loses type safety entirely (everything is text until parsed) and can’t easily pass more than one column of data per “row.” A TVP’s table type can have as many columns as you need, each with its own real data type.

    Practice tip: Extend the CustomerNameList table type to include a second column (say, a minimum tier to filter by per name), and adjust the procedure and JOIN accordingly. Seeing a TVP carry more than one column per row is what makes its advantage over a comma-separated string genuinely click.

    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.

  • Creating Stored Procedures in SQL Server: SET NOCOUNT ON and the Basics

    Creating Stored Procedures in SQL Server: SET NOCOUNT ON and the Basics

    Chapter 2’s function types kept hitting the same wall: none of them can modify data or manage a transaction. A stored procedure is precompiled, reusable, parameterized T-SQL logic that removes that wall entirely — it can perform INSERT/UPDATE/DELETE, manage transactions, use full TRY/CATCH, return multiple result sets, and doesn’t have to return anything at all. This is where the procedural half of T-SQL really begins.

    Functions hit a wall. Procedures walk through it.FUNCTIONtries: INSERT/UPDATE/DELETEtries: TRY/CATCH, BEGIN TRAN✗ Msg 443 errorBLOCKEDSTORED PROCEDURE✓ INSERT / UPDATE / DELETE    ✓ BEGIN TRAN … COMMIT / ROLLBACK✓ full TRY/CATCH    ✓ multiple result sets    ✓ return nothing at allcompiled ONCE, plan reusedfirst EXEC compiles the plan;later calls skip straight to running it→ this is where parameter sniffing comes fromALTER PROCEDURE keepsEXECUTE grants. DROP + CREATEsilently wipes them — they don’tcome back automatically. ⚠️

    Your First Procedure

    CREATE PROCEDURE dbo.usp_GetCustomersByTier
        @tier NVARCHAR(20)
    AS
    BEGIN
        SET NOCOUNT ON; -- near-universal best practice, see below
        SELECT customer_id, name, email FROM dbo.Customer WHERE tier = @tier;
    END;
    GO
    
    EXEC dbo.usp_GetCustomersByTier @tier = 'premium';
    -- Equivalent, positional call — works but is fragile if parameter order ever changes:
    EXEC dbo.usp_GetCustomersByTier 'premium';

    The usp_ prefix is a long-standing naming convention (“user stored procedure”) — avoid the older sp_ prefix specifically, since SQL Server always checks the system master database first for anything named sp_*, adding a small but real, entirely avoidable lookup cost to every call.

    Why SET NOCOUNT ON Matters More Than It Looks

    Without it: every DML statement sends an extra “(N rows affected)” message to the client This measurably slows procedures with loops or many statements

    Without SET NOCOUNT ON, this extra network chatter can measurably slow down procedures that loop or run many statements, and can actively interfere with some client libraries and reporting tools that misinterpret the extra “rows affected” messages as additional result sets. Put it at the top of every procedure by default — there is essentially never a reason not to.

    A Procedure Precompiles — What That Actually Means

    Unlike an ad-hoc query sent fresh from an application each time, a stored procedure’s execution plan is compiled once (on first call, or after certain invalidating events like a statistics update) and reused on subsequent calls. This is a real, measurable performance advantage for frequently-run logic — but it’s also the exact mechanism behind parameter sniffing, a real gotcha covered fully once you reach the Performance Tuning course: the plan compiled for the first parameter value seen gets reused for every subsequent call, even ones with very differently-shaped data.

    ALTER, DROP, and Modifying Procedures Safely

    -- Change the body without dropping and losing permissions granted on it
    ALTER PROCEDURE dbo.usp_GetCustomersByTier
        @tier NVARCHAR(20)
    AS
    BEGIN
        SET NOCOUNT ON;
        SELECT customer_id, name, email, tier FROM dbo.Customer WHERE tier = @tier; -- added tier column
    END;
    GO
    
    DROP PROCEDURE IF EXISTS dbo.usp_GetCustomersByTier;
    Common mistake: Using DROP + CREATE to “update” a procedure in a production script. If any user or role was explicitly granted EXECUTE permission on that specific procedure, dropping it removes those grants entirely — they don’t automatically come back when you recreate it. ALTER PROCEDURE preserves permissions and is the safer choice for modifying an existing procedure.
    Practice tip: Create the example procedure above, then run EXEC sp_helptext 'dbo.usp_GetCustomersByTier' to see SQL Server hand back the exact source text it stored. This is a genuinely useful habit for inspecting procedures on a server where you don’t have the original script handy.

    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.

  • Global Temp Tables in SQL Server (##temp): Sharing Data Across Sessions Safely

    Global Temp Tables in SQL Server (##temp): Sharing Data Across Sessions Safely

    A global temp table (##) is visible to every session on the server — genuinely useful, and genuinely a concurrency hazard if you’re not careful. This is the least commonly needed of the three temp object types, and this lesson is honest about exactly when that rarity is justified.

    Everyone can see it — nobody locks it##SharedDriverSnapshotone physical table, tempdbread + write, no isolationSession ASELECT / INSERTSession BSELECT / INSERTSession CSELECT / INSERTMeanwhile: two sessions raceSession A: IF NOT EXISTS(…) → FALSE, runs INSERTno lock held between the check and the insertSession B: IF NOT EXISTS(…) → FALSE too, runs INSERTsame instant — classic check-then-insert raceDUPLICATE ROWboth sessions inserted ‘Amir’##temp gives you NO isolation for free

    Creating and Sharing One

    CREATE TABLE ##SharedDriverSnapshot (
        driver_name NVARCHAR(100),
        total_fare  DECIMAL(10,2)
    );
    
    INSERT INTO ##SharedDriverSnapshot
    SELECT driver_name, SUM(fare_usd) FROM dbo.TripAdvanced GROUP BY driver_name;
    
    -- Any other session, connected to the same server, can now see this:
    -- SELECT * FROM ##SharedDriverSnapshot;
    
    DROP TABLE ##SharedDriverSnapshot;

    When It’s Dropped

    Dropped when: creating session ends AND no other session is still referencing it

    This second condition is the subtle part: if Session A creates a global temp table and disconnects, but Session B is mid-query against it, SQL Server keeps it alive until Session B finishes — it doesn’t get yanked out from under an active reader. Once every referencing session is done, it’s cleaned up automatically.

    The Concurrency Risk

    Multiple sessions can write to a global temp table simultaneously with no built-in isolation between them, unlike a real table where you’d deliberately design locking/transactions around it (Chapter 9 covers this properly). Two sessions inserting at the same time won’t corrupt data, but two sessions racing to both check-then-insert (“if this row doesn’t exist yet, add it”) can both pass the check simultaneously and both insert — a classic race condition, worse here because it’s easy to forget a scratch table needs the same concurrency discipline as a real one.

    -- A race condition waiting to happen if two sessions run this concurrently:
    IF NOT EXISTS (SELECT 1 FROM ##SharedDriverSnapshot WHERE driver_name = 'Amir')
        INSERT INTO ##SharedDriverSnapshot VALUES ('Amir', 0);
    -- Both sessions can see "not exists" before either has inserted, producing a duplicate

    Legitimate Use Cases

    Use it for genuinely useful, narrow cases — sharing a debug snapshot between two active SSMS windows during a troubleshooting session, or coordinating a multi-step batch/ETL job where separate connections (sometimes even separate tools) need to hand off an intermediate result. Reason explicitly about concurrent access rather than assuming it’s safe by default; in most cases, a permanent staging table with proper locking, or simply passing data through parameters, is the more robust choice for anything beyond ad-hoc debugging.

    Common mistake: Reaching for a global temp table as a lazy way to “share state between two parts of my application” instead of using a real table or a proper message-passing mechanism. It works in a demo and then causes an intermittent, hard-to-reproduce bug in production the first time two requests overlap.

    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.