Category: SQL Server Advanced

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

  • Local Temp Tables in SQL Server (#temp): Scope, Statistics, and Real Use Cases

    Local Temp Tables in SQL Server (#temp): Scope, Statistics, and Real Use Cases

    Fundamentals Chapter 5 gave you a first look at local temp tables. This chapter goes further: the full scope rules, why they get real optimizer statistics (unlike table variables, next lesson), and where they genuinely earn their place over a CTE or subquery.

    Same name, separate copies(#temp tables are session-private)Session A#DriverTotalsreal stats + can be indexedSession B#DriverTotalsseparate physical copynever see each otherOuterProccreates #Sharedthen EXEC InnerProcInnerProcSELECT * FROM #Sharedinherited!Lifetime: dies whenthe outermost creatingscope ends (or DROP).📌

    Using One

    CREATE TABLE #DriverTotals (
        driver_name NVARCHAR(100),
        total_fare  DECIMAL(10,2),
        trip_count  INT
    );
    
    INSERT INTO #DriverTotals
    SELECT driver_name, SUM(fare_usd), COUNT(*)
    FROM dbo.TripAdvanced
    GROUP BY driver_name;
    
    -- Can be indexed, just like a real table
    CREATE CLUSTERED INDEX IX_Temp_DriverName ON #DriverTotals (driver_name);
    
    SELECT * FROM #DriverTotals WHERE total_fare > 30;
    
    DROP TABLE #DriverTotals;

    Real Statistics, Real Indexes

    #temp behaves like a real table in tempdb Maintains real statistics → optimizer makes good decisions Supports CREATE INDEX after creation

    This is what makes local temp tables genuinely useful for staging large intermediate results in a complex report query — the optimizer isn’t flying blind the way it is with a table variable (next lesson makes this contrast concrete). SQL Server automatically creates and updates statistics on a #temp table’s data, exactly as it would for a permanent table, which means row-count estimates for anything querying it afterward are generally accurate.

    The Scope Rule, Precisely

    A local temp table created inside a stored procedure is visible to that procedure and anything it calls (nested procedures can see and use a caller’s #temp table — a genuinely useful pattern for passing staged data down a call chain), but disappears when the outermost creating scope ends. If two different sessions both create #DriverTotals, SQL Server silently gives them separate, isolated copies internally (renamed behind the scenes with a unique suffix) — they never collide, and neither session can see the other’s version.

    CREATE PROCEDURE dbo.OuterProc AS
    BEGIN
        CREATE TABLE #Shared (id INT);
        INSERT INTO #Shared VALUES (1);
        EXEC dbo.InnerProc; -- InnerProc can see and use #Shared
    END;
    GO
    CREATE PROCEDURE dbo.InnerProc AS
    BEGIN
        SELECT * FROM #Shared; -- works: nested procs inherit the caller's temp tables
    END;

    When a Temp Table Beats a CTE

    A CTE (covered in full in Chapter 6) is often the more elegant choice for a single query — but it’s re-evaluated wherever it’s referenced within that query, and doesn’t persist statistics or an index of its own. A local temp table earns its place specifically when: the same intermediate result is queried multiple times across several statements, the intermediate result is large enough that having a real index on it matters, or you need to break a genuinely complex multi-stage transformation into readable, individually-testable steps within a longer script or procedure.

    Common mistake: Reaching for a temp table out of habit for every intermediate step, even single-use ones inside one query, where a CTE would be simpler and equally fast. Temp tables have real overhead — tempdb I/O, statistics maintenance — that a CTE inside a single statement avoids entirely.
    Practice tip: Rebuild the nested-procedure example above yourself, then try querying #Shared from a completely separate session while OuterProc is still running — confirm it genuinely isn’t visible there, making the isolation concrete rather than theoretical.

    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.

  • Table Variables in SQL Server: The Rollback Behavior That Actually Matters

    Table Variables in SQL Server: The Rollback Behavior That Actually Matters

    Table variables look like a smaller, simpler version of a temp table. The real difference is behavioral, not just syntactic — and it’s specific enough to be one of the most commonly asked SQL Server interview questions, covered again in Chapter 11.

    Same ROLLBACK, different fate(table variable vs #temp table, one transaction)@Log (table variable)INSERT INTO @Log VALUES(…)inside BEGIN TRAN#Log (#temp table)INSERT INTO #Log VALUES(…)inside the SAME BEGIN TRANROLLBACK;row still theretable var survives the rollbackEMPTY#temp table’s DML rolled back tooWhy? A table var isn’tlogged like #temp DML —SQL Server treats it morelike an ordinary variable. 📌

    Using One

    DECLARE @DriverTotals TABLE (
        driver_name NVARCHAR(100),
        total_fare  DECIMAL(10,2)
    );
    
    INSERT INTO @DriverTotals
    SELECT driver_name, SUM(fare_usd)
    FROM dbo.TripAdvanced
    GROUP BY driver_name;
    
    SELECT * FROM @DriverTotals ORDER BY total_fare DESC;

    The Behavior That Actually Surprises People in Production

    DECLARE @Log TABLE (msg NVARCHAR(200));
    CREATE TABLE #Log (msg NVARCHAR(200));
    
    BEGIN TRAN;
        INSERT INTO @Log VALUES ('table variable row');
        INSERT INTO #Log VALUES ('temp table row');
    ROLLBACK;
    
    SELECT * FROM @Log;  -- still has the row!
    SELECT * FROM #Log;  -- empty — rolled back
    DROP TABLE #Log;

    If you’re logging errors into a table variable inside a transaction that then fails and rolls back, the table variable’s contents survive — that’s often exactly the behavior you want for an error log, and exactly why table variables exist as a distinct tool, not just “a smaller #temp table.” The underlying reason: a table variable is not itself part of the surrounding transaction’s log the same way a #temp table’s DML operations are — SQL Server treats it more like an ordinary local variable for durability purposes, even though it physically also lives in tempdb.

    -- The realistic use case: an error log that survives the very rollback it's reporting on
    BEGIN TRY
        BEGIN TRAN;
            UPDATE dbo.Employee SET salary = salary * 1.1;
            INSERT INTO @Log VALUES ('About to check a business rule...');
            IF EXISTS (SELECT 1 FROM dbo.Employee WHERE salary > 1000000)
                THROW 51000, 'Salary cap exceeded', 1;
        COMMIT;
    END TRY
    BEGIN CATCH
        ROLLBACK;
        INSERT INTO @Log VALUES ('Rolled back: ' + ERROR_MESSAGE());
    END CATCH;
    SELECT * FROM @Log; -- both log entries are here, even though the UPDATE itself was undone

    Quick Comparison

    #temp table @table variable
    Scope Session (and nested calls) Batch or procedure only
    Statistics Real, maintained Historically none (improved somewhat in 2019+ with deferred compilation, but still generally weaker)
    Transaction rollback Rolled back Survives
    Can be altered after creation Yes (ADD COLUMN, CREATE INDEX) No — structure is fixed at DECLARE time

    That last row matters in practice too: if you need to add an index to your intermediate result after seeing what the data looks like, or need to ALTER its structure partway through a script, a table variable can’t do that — its full structure must be declared upfront.

    Practice tip: Rebuild the TRY/CATCH error-logging example above yourself, and swap @Log for a #Log temp table to see the log entries vanish along with the rollback. That side-by-side comparison, run yourself, is the fastest way to make this genuinely memorable rather than a fact you memorized for an interview.

    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.

  • Scalar vs Inline TVF vs Multi-Statement TVF: Choosing the Right SQL Server Function Type

    Scalar vs Inline TVF vs Multi-Statement TVF: Choosing the Right SQL Server Function Type

    Three function types, three very different performance profiles — now that you’ve built one of each, here’s the decision made simple, plus the stored-procedure exit ramp that applies whenever none of the three actually fit.

    Which function do you need?Need a valueor a table back?(start here)Scalar Function→ one value outprofile before WHERE useInline TVF→ a table, 1 SELECTDEFAULT PICKMulti-Statement TVF→ a table, multi-stepblack-box row estimatemodify data?Stored ProcedureINSERT / UPDATE / DELETEtransactions, TRY/CATCHRule of thumb:default to iTVF.Everything else is adeliberate exception. 📌

    Side by Side

    Scalar Inline TVF Multi-statement TVF
    Returns One value A table A table
    Optimizer visibility Limited (improved 2019+) Full — inlines like a view None — black box, fixed row estimate
    Procedural logic Yes No, single SELECT only Yes
    Usable inside a JOIN N/A (scalar value) Yes, joins like a table Yes, joins like a table
    Default preference 3rd choice 1st choice Last resort — consider a procedure first

    The Decision Flow

    Can 1 SELECT do it? Yes → Inline TVF No → need one value? No → mTVF or Procedure Yes → Scalar Function

    The Real Decision Isn’t Always Among These Three

    The most important line in the flowchart is the last branch: “mTVF or Procedure.” If you need to modify data (INSERT/UPDATE/DELETE), manage a transaction, or use TRY/CATCH error handling — none of which any function type can do — that’s your unambiguous signal to write a stored procedure instead, which is exactly where the next chapter picks up. Functions are for computing and returning values; procedures are for doing things, including things that change data.

    -- FAILS: functions cannot modify data outside a local table variable
    CREATE FUNCTION dbo.BadIdea (@id INT) RETURNS INT AS
    BEGIN
        UPDATE dbo.Employee SET last_login = GETDATE() WHERE employee_id = @id; -- not allowed
        RETURN 1;
    END;
    -- Msg 443: Invalid use of a side-effecting operator 'UPDATE' within a function.

    This restriction isn’t arbitrary — it’s what allows functions to be safely called from inside a SELECT list or WHERE clause at all. If functions could silently modify data, using one inside a SELECT would make the query’s meaning depend on evaluation order, which SQL’s set-based model deliberately doesn’t guarantee.

    Key Takeaways

    • Default to iTVF whenever a single SELECT expresses the logic — it’s the best-performing option, with zero downside versus writing the JOIN by hand
    • Scalar functions are fine for reusable expressions on modest data volumes, called outside a large table’s WHERE clause
    • mTVFs and stored procedures both handle procedural logic — if you need to modify data or manage transactions, that’s your firm signal to reach for a procedure instead, since no function type permits it
    Practice tip: Take one real reporting need from your own work or a hobby project, and walk it through this decision flow explicitly before writing any code — “can one SELECT do it? do I need one value or a table? am I modifying data?” Making this a conscious three-question checklist, rather than an instinct, is what actually sticks.

    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.

  • Multi-Statement Table-Valued Functions in SQL Server: When You Need Procedural Logic

    Multi-Statement Table-Valued Functions in SQL Server: When You Need Procedural Logic

    Sometimes a single SELECT genuinely can’t express what you need — the previous lesson’s exact limitation. That’s what multi-statement TVFs (mTVFs) are for, at a real, measurable cost that’s worth understanding before reaching for one.

    Estimated 100… actual 3(the mTVF row-estimate problem)mTVFblack box,fixed guessEstimated100 rowsActual3 rows!bad join plan chosennested loop, when hash joinwould’ve been dramatically fasterNot your indexes. Not stale stats.The row estimate is just… made up. 🔍Profile before blaming the wrong thing.

    Writing One

    CREATE FUNCTION dbo.GetSalaryBands ()
    RETURNS @Bands TABLE (
        band_name NVARCHAR(20),
        employee_count INT,
        avg_salary DECIMAL(10,2)
    )
    AS
    BEGIN
        INSERT INTO @Bands
        SELECT
            CASE WHEN salary < 65000 THEN 'Junior'
                 WHEN salary BETWEEN 65000 AND 85000 THEN 'Mid'
                 ELSE 'Senior' END,
            COUNT(*), AVG(salary)
        FROM dbo.Employee
        GROUP BY CASE WHEN salary < 65000 THEN 'Junior'
                      WHEN salary BETWEEN 65000 AND 85000 THEN 'Mid'
                      ELSE 'Senior' END;
        RETURN;
    END;
    GO
    
    SELECT * FROM dbo.GetSalaryBands();

    Notice the return type: RETURNS @Bands TABLE (...) defines an explicit table-variable shape, populated with ordinary INSERT statements inside a BEGIN/END body — fundamentally different from an iTVF's single implicit RETURN (SELECT ...). This structure is exactly what buys you procedural freedom (multiple statements, variables, even loops) at the cost described below.

    The Real Cost

    mTVFs lose the inlining benefit of an iTVF entirely — SQL Server treats the whole thing as a black box with a fixed row-count estimate (historically 1 row on older versions, 100 on newer defaults, version-dependent), regardless of how many rows the function actually returns. If a query joins the result of an mTVF to a large table, the optimizer's wildly wrong row estimate can lead it to choose a bad join strategy — a nested loop where a hash join would've been dramatically faster, for instance — for reasons that have nothing to do with your indexes or statistics being stale.

    -- Confirm the black-box estimate yourself: enable the actual execution plan
    -- and hover over the mTVF call in the plan — the Estimated Number of Rows
    -- will not match reality, even though this specific function returns exactly 3 rows.
    SELECT * FROM dbo.GetSalaryBands();

    The Rule of Thumb

    Prefer an iTVF whenever a single SELECT can express the logic. Reach for an mTVF only when you genuinely need procedural steps — and even then, seriously consider whether a stored procedure is actually the better fit, since mTVFs can't be indexed, can't be updated through, and carry the poor cardinality estimation shown above. A stored procedure returning a result set has none of these specific limitations, at the cost of not being directly usable inside a larger SELECT/JOIN the way a table-valued function is.

    Common mistake: Reaching for an mTVF purely out of habit because it feels more "function-like" than a stored procedure, without weighing that the black-box row estimate can silently degrade a much larger surrounding query's plan. If the mTVF's result is ever joined to another sizable table, this isn't a theoretical concern — profile it.
    Practice tip: Rewrite GetSalaryBands as a stored procedure instead (using a plain SELECT with the same CASE/GROUP BY logic, no RETURNS TABLE). Compare how you'd call each one — SELECT * FROM dbo.GetSalaryBands() vs EXEC dbo.GetSalaryBandsProc — and notice the procedure can't be directly joined into another query the way the function can. That tradeoff, not raw performance alone, is often the deciding factor in practice.

    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.

  • Inline Table-Valued Functions in SQL Server: Parameterized Views That Actually Perform

    Inline Table-Valued Functions in SQL Server: Parameterized Views That Actually Perform

    An inline TVF (iTVF) is essentially a parameterized view — a single SELECT statement wrapped in a function, returning a table. Unlike scalar functions, these get inlined into the calling query’s execution plan, which is why this lesson comes with none of the previous one’s performance warnings.

    Inlined into the plan, like a viewDept: SalesDept: EngDept: HRCROSS APPLYGetEmployeesByDept()single SELECT bodyinlines = same plan as the JOINscalar / mTVFoptimizer treats it as aBLACK BOXProof, not faith: in SSMS pressCtrl+M and compare plans — a trueiTVF’s plan is IDENTICAL to the JOIN. 🔍

    Writing and Using One

    CREATE FUNCTION dbo.GetEmployeesByDepartment (@dept NVARCHAR(50))
    RETURNS TABLE
    AS
    RETURN (
        SELECT employee_id, first_name, last_name, salary
        FROM dbo.Employee
        WHERE department = @dept
    );
    GO
    
    SELECT * FROM dbo.GetEmployeesByDepartment('Engineering');
    
    -- Can be joined just like a table
    SELECT e.first_name, e.salary
    FROM dbo.GetEmployeesByDepartment('Sales') e
    WHERE e.salary > 60000;
    
    -- Can even be CROSS APPLY'd per row of another table (covered fully in Chapter 6)
    SELECT d.department_name, top_earner.first_name
    FROM dbo.Department d
    CROSS APPLY (SELECT TOP 1 * FROM dbo.GetEmployeesByDepartment(d.department_name) ORDER BY salary DESC) top_earner;

    That last example is worth pausing on: an iTVF can take a value from the outer query as its parameter, once combined with CROSS APPLY — something a plain view can never do, since a view has no parameters at all. This is one of the most useful, distinctly-SQL-Server patterns in the whole language.

    Why iTVFs Perform Well

    Inline TVF Single SELECT body Optimizer sees inside it Scalar / mTVF Procedural body Optimizer treats as a black box

    Because SQL Server can see straight into an iTVF’s single SELECT, it substitutes the function call with the equivalent SQL directly — conceptually similar to how a compiler inlines a small function — and produces accurate row estimates, exactly as if you’d hand-written the JOIN yourself. No separate execution step, no performance penalty, no black-box row-count guessing.

    Proving the Inlining, Not Just Trusting It

    -- Compare the execution plan of the function call...
    SELECT * FROM dbo.GetEmployeesByDepartment('Engineering') WHERE salary > 70000;
    
    -- ...against the hand-written equivalent. In SSMS, enable "Include Actual Execution Plan"
    -- (Ctrl+M) for both and compare — for a true iTVF, the plans are identical.
    SELECT employee_id, first_name, last_name, salary
    FROM dbo.Employee WHERE department = 'Engineering' AND salary > 70000;
    Practice tip: Run both queries above with the actual execution plan visible and confirm they’re the same shape. This is the single best way to build real trust in “iTVFs are free” instead of just accepting it as a rule to memorize.

    The One Real Limitation

    An iTVF’s body must be exactly one SELECT statement — no variables, no IF/ELSE, no intermediate steps. The moment your logic needs a second statement (populate a temp result, then filter it based on something computed in step one), you’ve outgrown an iTVF and need either a multi-statement TVF (next lesson) or a restructured single query using a CTE, which often accomplishes the same goal without leaving iTVF territory at all.


    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.

  • Scalar Functions in SQL Server: Reusable Logic With a Real Performance Cost

    Scalar Functions in SQL Server: Reusable Logic With a Real Performance Cost

    Chapter 4 of the Fundamentals course previewed that user-defined functions exist. This is that promise kept — starting with the simplest kind. A scalar function takes parameters and returns a single value, usable anywhere an expression is valid, exactly like a built-in function such as DATEDIFF. Simple to write, but genuinely not free at scale, which is the entire reason this chapter exists before stored procedures.

    Called once… or once per row?row 1row 2row 3…thousands moreCalculateAge()called once PER ROWoptimizercan’t see inside the fn2019+: UDF inlining sometimes rescues thisDATEDIFF(…) inlineevaluated directly — optimizer sees itFew hundred rows? Fine.Millions of rows? PROFILE IT — aWHERE-clause fn call can be the realbottleneck. No index will fix it. ⚠️

    Writing One

    CREATE FUNCTION dbo.CalculateAge (@birthDate DATE)
    RETURNS INT
    AS
    BEGIN
        RETURN DATEDIFF(YEAR, @birthDate, GETDATE())
             - CASE WHEN (MONTH(@birthDate) > MONTH(GETDATE()))
                      OR (MONTH(@birthDate) = MONTH(GETDATE()) AND DAY(@birthDate) > DAY(GETDATE()))
                    THEN 1 ELSE 0 END;
    END;
    GO
    
    SELECT first_name, dbo.CalculateAge(birth_date) AS age FROM dbo.Employee;

    Notice this fixes the exact DATEDIFF boundary-crossing gotcha from the Fundamentals course (Chapter 4) — a naive DATEDIFF(YEAR, birth_date, GETDATE()) overcounts by one until the birthday actually passes this year. Wrapping the correct logic in a function means every caller gets it right automatically, instead of everyone re-deriving (and possibly getting wrong) the same CASE expression.

    The Performance Trap

    WHERE dbo.CalculateAge(birth_date) > 30 Called once PER ROW — can block query parallelization on large tables (pre-2019, and sometimes still)

    Scalar UDFs referenced in a WHERE clause or SELECT list against every row of a large table historically prevent SQL Server from parallelizing the query and get called once per row — the query optimizer has no visibility into what’s happening inside the function body, so it can’t reason about it the way it reasons about ordinary SQL. SQL Server 2019+ improved this significantly with “scalar UDF inlining,” which can automatically rewrite qualifying simple functions into inline expressions — but it’s still something to profile, not assume is free, since not every function qualifies (ones using TRY/CATCH, temp tables, or certain other constructs opt out of inlining).

    -- Seeing the difference directly: compare actual execution plans
    SET STATISTICS TIME ON;
    SELECT COUNT(*) FROM dbo.Employee WHERE dbo.CalculateAge(birth_date) > 30;
    SELECT COUNT(*) FROM dbo.Employee WHERE DATEDIFF(YEAR, birth_date, GETDATE()) > 30; -- inline equivalent
    SET STATISTICS TIME OFF;
    Common mistake: Assuming a scalar function is “basically the same speed” as inline logic because it looks similarly simple in the CREATE FUNCTION body. On a table with a few hundred rows, you likely won’t notice a difference. On a table with millions of rows, a row-by-row function call in the WHERE clause can be the single biggest bottleneck in an otherwise well-indexed query — and it won’t show up as an obvious “missing index” warning, since the problem isn’t the index, it’s the function call itself.

    When Scalar Functions Are Genuinely the Right Call

    Despite the caveat above, scalar functions are still a good tool for reusable logic that’s called on modest data volumes, in a SELECT list rather than a WHERE clause on a huge table, or inside a stored procedure operating on a handful of rows at a time. A formatting function for phone numbers, a business-rule lookup used in a report generated nightly — these are fine. The concern scales specifically with row count and where in the query the function appears, not with the mere existence of a scalar function in your codebase.

    DETERMINISTIC vs Not: A Subtle Constraint

    -- This function CANNOT be used in a computed persisted column or an index,
    -- because GETDATE() makes it non-deterministic (a different result each call):
    CREATE FUNCTION dbo.DaysUntilToday (@date DATE) RETURNS INT
    AS BEGIN RETURN DATEDIFF(DAY, @date, GETDATE()); END;

    A function is deterministic only if it always returns the same output for the same input, with no dependency on the current date/time, random values, or external state. This matters beyond just style — SQL Server won’t let you use a non-deterministic function in a persisted computed column or as part of an index key, because the stored value would go stale the moment time passes.

    Practice tip: Write dbo.CalculateAge yourself from the example above, then run it against a table with a few thousand synthetic rows (a numbers-table trick like the ones used in later capstones works well) in a WHERE clause, timing it with SET STATISTICS TIME ON. Compare against the inline DATEDIFF equivalent. Seeing an actual millisecond difference on your own machine is far more convincing than any explanation of why it happens.

    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.

  • Dynamic SQL in SQL Server: sp_executesql and Avoiding SQL Injection

    Dynamic SQL in SQL Server: sp_executesql and Avoiding SQL Injection

    Dynamic SQL builds a query as a string at runtime — necessary for variable table/column names, which can’t be parameterized normally (you can parameterize a WHERE value, but not “which table to query”). Done wrong, it’s also the single most common source of SQL injection, a vulnerability that has caused real, well-documented data breaches. This lesson treats that risk with the seriousness it deserves.

    Poisoned string vs. locked parameterUNSAFE: string concat…name = ”’ + @userInput + ”’input becomes part of the SQL text@userInput = ‘;DROP TABLE Product;–‘table dropped. game over.(the trailing — hides the rest)SAFE: sp_executesql…WHERE price >= @MinPricevalue passed through a locked channelidentifiers? QUOTENAME([col])wraps in brackets — safe even for reserved wordstable/column names can’t be parameters

    The Safe Pattern

    DECLARE @tableName SYSNAME = 'Product';
    DECLARE @sql NVARCHAR(MAX);
    
    SET @sql = N'SELECT COUNT(*) AS row_count FROM ' + QUOTENAME(@tableName);
    EXEC sp_executesql @sql;
    
    -- Parameterized dynamic SQL — the SAFE way to inject user-supplied VALUES
    DECLARE @minPrice DECIMAL(10,2) = 20.00;
    SET @sql = N'SELECT name, price FROM dbo.Product WHERE price >= @MinPrice';
    EXEC sp_executesql @sql, N'@MinPrice DECIMAL(10,2)', @MinPrice = @minPrice;

    How Injection Actually Happens

    -- NEVER do this — direct string concatenation of user input is a SQL injection hole
    DECLARE @userInput NVARCHAR(100) = ''';DROP TABLE dbo.Product;--';
    DECLARE @unsafeSql NVARCHAR(MAX) = N'SELECT * FROM dbo.Product WHERE name = ''' + @userInput + '''';
    -- @unsafeSql is now: SELECT * FROM dbo.Product WHERE name = '';DROP TABLE dbo.Product;--'
    -- If executed, this drops the table.

    Walk through exactly why this works: the attacker’s input closes the intended string literal early with a stray ', appends a semicolon to start a brand-new statement, adds their own malicious SQL (DROP TABLE...), and then -- comments out whatever was supposed to follow in the original query, so it doesn’t cause a syntax error. Every part of that trick relies on user input being treated as executable code text rather than as inert data — which is exactly what sp_executesql parameters prevent, by keeping the query’s shape fixed and passing values through a separate channel the engine can never reinterpret as code.

    The Rule, Visualized

    Identifiers Table/column names Wrap with QUOTENAME() Values User-supplied data Always sp_executesql params

    Table/column/schema names must be concatenated (there’s no parameter placeholder for “which table”), sanitized with QUOTENAME(), which wraps the identifier in brackets and escapes any embedded bracket characters — this is what makes it safe, not just convention. But actual data values must always go through sp_executesql parameters, never string concatenation.

    QUOTENAME Isn’t Optional Even for “Trusted” Input

    -- Without QUOTENAME, a table name containing a bracket or reserved word breaks or worse:
    DECLARE @table SYSNAME = 'Order'; -- a reserved keyword
    SET @sql = N'SELECT * FROM ' + @table; -- syntax error, or worse if attacker-controlled
    
    -- With QUOTENAME, it's safely wrapped regardless of content:
    SET @sql = N'SELECT * FROM ' + QUOTENAME(@table); -- becomes: SELECT * FROM [Order]
    Common mistake: Assuming dynamic SQL is only risky when input comes directly from a web form. Any value that ultimately traces back to something a user can influence — a config setting they can edit, a CSV they upload that gets read into a variable, a table/column name selected from a dropdown — needs the same treatment. “Internal tool, so it’s fine” is exactly the reasoning that leads to real incidents.

    Why Use Dynamic SQL At All?

    Given the risk, it’s worth being clear about when dynamic SQL is genuinely the right tool: building a search query with an unpredictable number of optional filters, generating administrative scripts that operate across a variable list of tables, or building reports where the pivoted columns aren’t known until runtime. For everything else — the vast majority of real T-SQL — a normal parameterized query or stored procedure with fixed parameters is simpler, safer, and lets the query optimizer cache and reuse execution plans more effectively.

    Practice tip: Take the unsafe concatenation example above, actually build the malicious string yourself in a scratch variable, and PRINT it (without executing it) to see exactly what SQL an attacker’s input would produce. Seeing the constructed statement in full is far more convincing than reading about the risk abstractly.

    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.

  • T-SQL Variables, Batches, and Control-of-Flow: Beyond Basic Queries

    T-SQL Variables, Batches, and Control-of-Flow: Beyond Basic Queries

    This is where SQL Server stops being just a query language and starts behaving like a real programming environment. Everything in the SQL Server Fundamentals course was declarative — describe the result you want, let the engine figure out how. Variables, IF/ELSE, and loops introduce procedural logic on top of that, which is exactly what stored procedures, functions, and triggers (the rest of this course) are built from.

    Control flow, sketched out(procedural logic, layered on SQL)DECLARE @xbatch-scoped variableIF / ELSEbranches the logiciterates…WHILErow-by-row, last resortGO@x is GONEnew batch = new scopeRow-by-row WHILE loop?Ask first: could this be ONEUPDATE / MERGE statementinstead? Usually, yes. 📝

    Declaring and Using Variables

    DECLARE @threshold DECIMAL(10,2) = 50.00;
    DECLARE @productCount INT;
    
    SELECT @productCount = COUNT(*) FROM dbo.Product WHERE price > @threshold;
    PRINT 'Products above threshold: ' + CAST(@productCount AS VARCHAR(10));
    
    IF @productCount > 0
        PRINT 'At least one premium product exists.';
    ELSE
        PRINT 'No premium products found.';

    Notice SELECT @productCount = COUNT(*) ... — this is SQL Server’s idiom for assigning a query’s result directly into a variable, distinct from a normal SELECT that returns a result set. If the query returns zero rows, the variable is set to NULL, not left unchanged; if it returns multiple rows, the variable silently ends up holding the last row’s value, which is rarely what you want and worth watching for.

    The Batch-Scoping Trap

    DECLARE @x INT = 5; GO new batch starts here PRINT @x; Error: @x doesn’t exist in this batch

    A batch is a group of statements sent to SQL Server together, separated by GO (a client-side signal understood by SSMS/sqlcmd, not real T-SQL — the server itself never sees it). Variables declared in one batch don’t exist in the next, because each batch is compiled and executed as an entirely separate unit. This trips people up constantly when copy-pasting scripts with stray GO statements in the middle, or when a script generator inserts a GO you didn’t expect.

    -- Also worth knowing: DDL statements sometimes REQUIRE their own batch
    CREATE VIEW dbo.vw_Test AS SELECT 1 AS col; -- must be the first/only statement in its batch
    GO

    WHILE Loops: A Last Resort, Not a First Instinct

    DECLARE @i INT = 1;
    WHILE @i <= 3
    BEGIN
        PRINT 'Iteration ' + CAST(@i AS VARCHAR(10));
        SET @i += 1;
    END;

    WHILE loops process one row at a time and are almost always slower than an equivalent set-based UPDATE/SELECT — this is the single biggest mental adjustment for developers coming from procedural languages, where a for-loop is the default tool. In SQL Server, reach for a loop only when the logic genuinely can't be expressed as a single set operation (batch-processing a huge table in chunks to avoid one giant transaction is a legitimate use case; iterating row by row to do what an UPDATE could do in one statement is not).

    -- The set-based equivalent of "loop through rows and apply a 10% discount" —
    -- no loop needed, and dramatically faster on any real table size:
    UPDATE dbo.Product SET price = price * 0.9 WHERE category = 'Clearance';
    Common mistake: Writing a WHILE loop that fetches one row, processes it, and moves to the next — essentially reimplementing a cursor by hand. If you find yourself doing this, stop and ask whether the whole operation can be expressed as one UPDATE, INSERT...SELECT, or MERGE statement instead. It almost always can.

    BEGIN/END Blocks and Nesting

    DECLARE @orderTotal DECIMAL(10,2) = 150.00;
    
    IF @orderTotal > 100
    BEGIN
        IF @orderTotal > 500
            PRINT 'Free shipping + gift wrap';
        ELSE
            PRINT 'Free shipping';
    END
    ELSE
        PRINT 'Standard shipping rate applies';

    Unlike braces in C-style languages, BEGIN/END is only strictly required to group multiple statements under one IF/WHILE — a single statement doesn't need it, as the ELSE branch above shows. Many teams require BEGIN/END everywhere regardless, purely for consistency and to avoid a bug where someone adds a second statement to a branch and forgets it silently falls outside the IF.

    Practice tip: Take the WHILE loop example above and rewrite its logic (printing iteration numbers) is fine as a loop — it's genuinely sequential output. But then find a real "loop through rows and update each one" scenario in your own head and consciously rewrite it as a single set-based statement instead. That translation instinct is worth more than memorizing WHILE syntax.

    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.

  • TRY/CATCH Error Handling in SQL Server: THROW vs RAISERROR

    TRY/CATCH Error Handling in SQL Server: THROW vs RAISERROR

    Production T-SQL needs to fail gracefully, not just fail. An unhandled error in the middle of a multi-step operation can leave data in a half-finished state — TRY/CATCH, paired with transactions (covered in full in Chapter 9), is how you prevent that.

    Two paths through TRYBEGIN TRYruns like normal codeno errorrest of TRY runserror! jumps ⚡BEGIN CATCHrest of TRY is skippedTHROW;keeps original errorRAISERRORlegacy, pre-2012THROW 50000,…loses the original!

    The Pattern

    BEGIN TRY
        UPDATE dbo.Product SET stock_qty = stock_qty - 10 WHERE product_id = 2;
        IF (SELECT stock_qty FROM dbo.Product WHERE product_id = 2) < 0
            THROW 51000, 'Stock quantity cannot go negative.', 1;
    END TRY
    BEGIN CATCH
        PRINT 'Error caught: ' + ERROR_MESSAGE();
        PRINT 'Error number: ' + CAST(ERROR_NUMBER() AS VARCHAR(10));
        PRINT 'Error line: ' + CAST(ERROR_LINE() AS VARCHAR(10));
    END CATCH;

    Code inside BEGIN TRY ... END TRY runs normally. The instant any statement in that block raises an error, execution jumps immediately to BEGIN CATCH ... END CATCH — the rest of the TRY block is skipped entirely, similar to try/catch in C#, Java, or Python, but with SQL-Server-specific error inspection functions.

    THROW vs RAISERROR

    THROW (2012+) Simpler syntax Correctly re-raises original error RAISERROR (legacy) Older formatting features Pre-2012 compatibility

    THROW is preferred — it's simpler, and with no arguments inside a CATCH block, it correctly preserves the original error's number, severity, and state when re-thrown. Reach for RAISERROR only when you need its legacy formatting (%s/%d placeholders) or must support very old SQL Server versions.

    -- Re-throwing the ORIGINAL caught error, unchanged, after logging it:
    BEGIN TRY
        SELECT 1/0; -- deliberate divide-by-zero to trigger an error
    END TRY
    BEGIN CATCH
        PRINT 'Logged: ' + ERROR_MESSAGE();
        THROW; -- bare THROW re-raises the exact original error
    END CATCH;
    Common mistake: Calling THROW with your own custom message/number when you meant to just re-raise the original error for the caller to see. A bare THROW; (no arguments) inside CATCH re-throws exactly what was caught — THROW 50000, 'Something failed', 1; replaces it with a brand-new, less specific error that loses the original diagnostic detail.

    The Error Functions Toolkit

    Function Returns
    ERROR_NUMBER() The error's numeric code
    ERROR_MESSAGE() The human-readable error text
    ERROR_LINE() Line number where the error occurred
    ERROR_PROCEDURE() Procedure/function name, NULL if ad-hoc
    ERROR_SEVERITY() Severity level (11-19 typical for handleable errors)
    ERROR_STATE() A custom state number you can use to distinguish similar errors

    All six are only valid inside a CATCH block — called anywhere else, they simply return NULL, since there's no error context to describe.

    Nesting: A TRY/CATCH Inside a CATCH

    BEGIN TRY
        UPDATE dbo.Product SET stock_qty = stock_qty - 10 WHERE product_id = 2;
    END TRY
    BEGIN CATCH
        BEGIN TRY
            INSERT INTO dbo.ErrorLog (error_message, logged_at) VALUES (ERROR_MESSAGE(), SYSDATETIME());
        END TRY
        BEGIN CATCH
            PRINT 'Even the error logging failed — this is genuinely bad, escalate.';
        END CATCH;
        THROW;
    END CATCH;

    This is a real, defensible pattern in production code: log the error to a table for later diagnosis, but wrap the logging itself in its own TRY/CATCH — you don't want a failure in your error-logging code to mask or replace the original error.

    Practice tip: Trigger three different real errors on purpose — a divide by zero, a constraint violation, and a THROW with a custom message — and print all six ERROR_ functions for each inside a CATCH block. Seeing how the values differ across error types builds real intuition faster than reading the reference table 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.