Tag: T-SQL

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

  • NOT NULL and DEFAULT in SQL Server: Your First Line of Data Defense

    NOT NULL and DEFAULT in SQL Server: Your First Line of Data Defense

    Two lightweight rules you can put directly on a column, before the full constraint system (primary keys, foreign keys, CHECK) enters the picture in Chapter 6. Small as they look, they prevent an enormous share of real-world data-quality bugs โ€” the kind that surface as a mysterious blank field in a report three months later.

    NOT NULL & DEFAULT(two rules, one column at a time)NOT NULLrejects the INSERT if thevalue is missing entirelyDEFAULTauto-fills a value whenone isn’t providedINSERT INTO Support_Ticket (subject) VALUES (‘Cannot log in’);subject: ‘Cannot log in’ (yours)status: ‘open’ ยท priority: 2 ยท created_at: now() ยท ticket_guid: a fresh GUIDโ†‘ all four filled in automatically by DEFAULT โ€” you never mentioned themBackfill THEN constrain:UPDATE … WHERE x IS NULLbefore ALTER … NOT NULL.

    What NULL Actually Means

    NULL isn’t zero, an empty string, or “false” โ€” it specifically means unknown / not applicable / not yet provided. This has real, non-obvious consequences: NULL = NULL evaluates to unknown, not true, which is why you can’t write WHERE middle_name = NULL and must instead write WHERE middle_name IS NULL. Any arithmetic or string concatenation touching a NULL also produces NULL โ€” 5 + NULL is NULL, not 5. This single fact explains a large share of “why is my total wrong” bugs beginners hit later with SUM and string building.

    The Two Rules

    • NOT NULL โ€” forces every row to have a real value in that column; rejects the insert otherwise
    • DEFAULT โ€” auto-fills a value when one isn’t provided in the INSERT (a literal, or the result of a function call)

    Seeing Both in Action

    CREATE TABLE dbo.Support_Ticket (
        ticket_id    INT IDENTITY(1,1) PRIMARY KEY,
        subject      NVARCHAR(200) NOT NULL,
        status       NVARCHAR(20)  NOT NULL DEFAULT 'open',
        priority     TINYINT       NOT NULL DEFAULT 2,
        created_at   DATETIME2     NOT NULL DEFAULT SYSDATETIME(),
        ticket_guid  UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID()
    );
    
    -- status, priority, created_at, and ticket_guid all fill themselves in automatically:
    INSERT INTO dbo.Support_Ticket (subject) VALUES ('Cannot log in');
    SELECT * FROM dbo.Support_Ticket;

    DEFAULT isn’t limited to literal values โ€” SYSDATETIME() and NEWID() above are function calls, evaluated fresh at insert time for every row. This is the standard way to auto-timestamp a row or generate a GUID without the application needing to supply either.

    Adding NOT NULL to a Column That Already Has Data

    This trips up almost everyone the first time: you can’t simply tighten an existing nullable column if any row already has NULL in it.

    -- Table already has rows, some with NULL priority
    ALTER TABLE dbo.Support_Ticket ALTER COLUMN priority TINYINT NOT NULL;
    -- Msg 515: Cannot insert the value NULL into column 'priority' ...
    
    -- The real fix: backfill first, then tighten
    UPDATE dbo.Support_Ticket SET priority = 2 WHERE priority IS NULL;
    ALTER TABLE dbo.Support_Ticket ALTER COLUMN priority TINYINT NOT NULL;

    This exact two-step pattern โ€” backfill, then constrain โ€” is how real migrations tighten a loosely-defined column once you’ve decided it should never be empty going forward.

    Handling NULLs You Already Have: ISNULL and COALESCE

    Sometimes a column legitimately should allow NULL (a customer’s optional middle name), but you still need a sensible display value when querying it:

    SELECT subject, ISNULL(status, 'unknown') AS status FROM dbo.Support_Ticket;
    
    -- COALESCE takes any number of arguments, returns the first non-NULL one
    SELECT COALESCE(preferred_name, first_name, 'Guest') AS display_name FROM dbo.Customer;

    ISNULL is SQL-Server-specific and takes exactly two arguments; COALESCE is ANSI-standard, works across database engines, and accepts any number of fallback values โ€” generally the better default choice unless you have a specific reason to use ISNULL.

    What Happens Without Them

    No constraints subject: NULL status: NULL NOT NULL + DEFAULT subject: required, rejected if empty status: auto-fills ‘open’

    Without these two simple rules, incomplete or nonsensical rows slip in silently โ€” a support ticket with no subject, an order status that’s blank instead of a real state. You end up writing defensive checks in application code that the database could have enforced for free, and that check is only as good as every application and every developer remembering to write it, every single time. A NOT NULL constraint never forgets.

    Practice tip: When designing a table, default every column to NOT NULL and only relax it to nullable when you can name a real scenario where the value is genuinely unknown (a middle name, an optional phone number). This “NOT NULL unless proven otherwise” habit catches far more bugs than the reverse default.

    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 Fundamentals, coming soon on this site.

  • INSERT, UPDATE, DELETE in SQL Server: The Mistake That Wipes Your Table

    INSERT, UPDATE, DELETE in SQL Server: The Mistake That Wipes Your Table

    DML (Data Manipulation Language) statements change the data inside tables, not their structure. They’re also where the single most expensive beginner mistake in SQL happens โ€” and where a handful of less-obvious patterns (multi-row inserts, UPDATE with a JOIN, the OUTPUT clause) separate someone who can write basic DML from someone who’s actually fluent.

    INSERT ยท UPDATE ยท DELETE(and the missing WHERE that costs careers)INSERTadds new rowsUPDATEchanges existing rowsDELETEremoves rows, foreversame WHERE clause. wildly different stakes:UPDATE Employee SET salary=salary*1.05WHERE last_name=’Sharma’;exactly one row changesUPDATE Employee SET salary=0;— no WHERE clause at all๐Ÿ˜ฑ EVERY row. No undo.TRUNCATE isn’t DELETE:no WHERE, resets IDENTITY,skips triggers entirely.safe habit: SELECT the same WHERE first, then run the UPDATE

    INSERT: One Row, Many Rows, or From Another Query

    -- Single row
    INSERT INTO dbo.Employee (first_name, last_name, hire_date, salary)
    VALUES ('Priya', 'Sharma', '2024-03-01', 75000.00);
    
    -- Multiple rows in one statement โ€” one round trip instead of three
    INSERT INTO dbo.Employee (first_name, last_name, hire_date, salary)
    VALUES
        ('Alex', 'Chen', '2024-04-10', 68000.00),
        ('Jordan', 'Lee', '2024-05-02', 71000.00),
        ('Sam', 'Patel', '2024-05-15', 69500.00);
    
    -- INSERT ... SELECT โ€” copy rows from another table/query, no VALUES needed
    INSERT INTO dbo.Employee_Archive (first_name, last_name, hire_date, salary)
    SELECT first_name, last_name, hire_date, salary
    FROM dbo.Employee
    WHERE hire_date < '2020-01-01';

    INSERT ... SELECT is one of the most-used patterns in real T-SQL โ€” archiving old rows, seeding a new table from an existing one, or building the exact kind of synthetic test data later chapters use for performance work.

    UPDATE and DELETE: The Basics

    UPDATE dbo.Employee
    SET salary = salary * 1.05
    WHERE last_name = 'Sharma';
    
    DELETE FROM dbo.Employee
    WHERE employee_id = 2;
    
    TRUNCATE TABLE dbo.Employee; -- wipes ALL rows instantly, resets IDENTITY

    UPDATE Driven by Another Table

    A pattern beginners often don't discover for a while: updating one table based on values in another, using a JOIN directly inside the UPDATE:

    UPDATE e
    SET e.salary = e.salary * 1.10
    FROM dbo.Employee e
    JOIN dbo.Department d ON d.department_id = e.department_id
    WHERE d.name = 'Engineering';

    This gives every Engineering employee a 10% raise in one statement, without needing to first SELECT the matching IDs into a list. You'll get comfortable with the JOIN syntax itself in Chapter 5 โ€” file this pattern away for later.

    DELETE vs TRUNCATE: Not Interchangeable

    DELETE TRUNCATE
    WHERE clause Supported โ€” delete a subset Not allowed โ€” always removes every row
    Logging Logs each row individually Minimally logged โ€” much faster on large tables
    IDENTITY counter Unaffected โ€” next insert continues numbering Reset back to the seed value
    Triggers Fires any DELETE triggers Does not fire DELETE triggers
    Foreign keys Works even if referenced by another table (row by row) Fails if any other table has a foreign key pointing to this one

    Rule of thumb: reach for TRUNCATE only when you genuinely mean "empty this entire table and I don't care about triggers or per-row logging" โ€” typically scratch/staging tables. For anything with a WHERE clause, or that has triggers or dependent foreign keys, DELETE is the only option anyway.

    The Mistake That Costs Careers

    UPDATE Employee SET salary = 0 WHERE employee_id = 2; โœ… Updates exactly one row UPDATE Employee SET salary = 0; โŒ Zeroes out EVERY row in the table โ€” no confirmation, no undo

    Without a WHERE clause, UPDATE and DELETE apply to every row in the table. Always verify your WHERE clause as a SELECT first:

    -- Step 1: verify what you're about to change
    SELECT * FROM dbo.Employee WHERE last_name = 'Sharma';
    
    -- Step 2: only then run the UPDATE with the identical WHERE clause
    UPDATE dbo.Employee SET salary = salary * 1.05 WHERE last_name = 'Sharma';

    A Safer Habit: OUTPUT and Transactions

    Two techniques that make destructive DML meaningfully safer in practice. First, OUTPUT shows you exactly what changed, immediately:

    UPDATE dbo.Employee
    SET salary = salary * 1.05
    OUTPUT deleted.employee_id, deleted.salary AS old_salary, inserted.salary AS new_salary
    WHERE last_name = 'Sharma';

    Second, wrap risky DML in an explicit transaction so a mistake is one ROLLBACK away instead of permanent:

    BEGIN TRAN;
    UPDATE dbo.Employee SET salary = 0; -- oops, forgot the WHERE clause
    SELECT * FROM dbo.Employee; -- immediately obvious something's wrong
    ROLLBACK; -- undone, no harm done
    Practice tip: For any UPDATE/DELETE you're not fully confident in outside of a script you've tested, run it inside BEGIN TRAN ... ROLLBACK first as a dry run. Only re-run with COMMIT once the row count and OUTPUT look exactly right.

    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 Fundamentals, coming soon on this site.

  • CREATE, ALTER, DROP: SQL Server DDL Explained With Examples

    CREATE, ALTER, DROP: SQL Server DDL Explained With Examples

    DDL (Data Definition Language) statements define the structure of your database โ€” tables, columns, and their types. Every DDL statement in SQL Server takes effect immediately and, unusually among major databases, is fully transactional โ€” you can wrap DDL in BEGIN TRAN/ROLLBACK and undo it, which most other RDBMSes don’t allow.

    Build, Change, Destroy(and how to undo any of it)wrap ANY of these in BEGIN TRAN … ROLLBACK to undoCREATEbuild something newALTERcan fail if data doesn’t fitDROPgone. no undo. ever.risk increases โ†’CREATE TABLE IF NOT EXISTSis NOT valid T-SQL! Use:IF OBJECT_ID(…) IS NULLBEGIN … END instead.

    CREATE: Build a New Table

    CREATE TABLE dbo.Employee (
        employee_id   INT IDENTITY(1,1) PRIMARY KEY,
        first_name    NVARCHAR(50)  NOT NULL,
        last_name     NVARCHAR(50)  NOT NULL,
        hire_date     DATE          NOT NULL DEFAULT GETDATE(),
        salary        DECIMAL(10,2) NOT NULL
    );

    Notice the dbo. prefix โ€” that’s the schema name. dbo (database owner) is the default schema every database starts with; schemas are namespaces that let you group related tables (e.g. sales.Order vs hr.Employee) and manage permissions per group. Always schema-qualify your table names in real code โ€” unqualified names resolve against whatever the current user’s default schema happens to be, which is a subtle source of “works on my machine” bugs.

    Guarding CREATE Against Re-Running a Script

    CREATE TABLE IF NOT EXISTS dbo.Scratch_Test (id INT); -- NOT valid T-SQL!
    
    -- The actual SQL Server idiom:
    IF OBJECT_ID('dbo.Scratch_Test', 'U') IS NULL
    BEGIN
        CREATE TABLE dbo.Scratch_Test (id INT);
    END
    Common mistake: Copying CREATE TABLE IF NOT EXISTS syntax from MySQL/PostgreSQL tutorials โ€” it’s a syntax error in T-SQL. The OBJECT_ID(...) IS NULL check above is the standard SQL Server equivalent, and you’ll see it constantly in real migration scripts.

    ALTER: Change an Existing Table

    ALTER TABLE dbo.Employee ADD email NVARCHAR(100) NULL;
    ALTER TABLE dbo.Employee ALTER COLUMN salary DECIMAL(12,2) NOT NULL;
    ALTER TABLE dbo.Employee DROP COLUMN email;

    Two things about ALTER COLUMN that surprise beginners: first, widening a type (DECIMAL(10,2) โ†’ DECIMAL(12,2), or VARCHAR(50) โ†’ VARCHAR(100)) is safe and fast. Narrowing one โ€” or changing NULL to NOT NULL on a column that already has NULL values โ€” fails outright if existing data can’t fit the new definition. SQL Server checks every existing row before allowing the change.

    -- This fails if any existing row has a NULL email:
    ALTER TABLE dbo.Employee ALTER COLUMN email NVARCHAR(100) NOT NULL;
    -- Msg 515: Cannot insert the value NULL into column 'email' ...
    
    -- The real-world fix: clean the data first, then tighten the constraint
    UPDATE dbo.Employee SET email = 'unknown@example.com' WHERE email IS NULL;
    ALTER TABLE dbo.Employee ALTER COLUMN email NVARCHAR(100) NOT NULL;

    Renaming Things (It’s Not ALTER)

    Unlike some databases, T-SQL doesn’t rename objects through ALTER โ€” it uses a dedicated system procedure:

    EXEC sp_rename 'dbo.Employee.email', 'contact_email', 'COLUMN';
    EXEC sp_rename 'dbo.Employee', 'Staff';
    Caution: sp_rename does not update any views, stored procedures, or application code referencing the old name โ€” it only changes the object’s internal metadata name. Renaming a production table is a bigger operation than it looks.

    DROP: Permanently Remove an Object

    DROP TABLE IF EXISTS dbo.Scratch_Test;

    Unlike CREATE TABLE IF NOT EXISTS, DROP TABLE IF EXISTS genuinely is valid modern T-SQL (SQL Server 2016+) โ€” the asymmetry is just a quirk of which syntax Microsoft added and when.

    The Danger Zone, Visualized

    CREATE / ALTER Safe, reversible-ish DROP TABLE Data + structure, gone. No undo.

    DROP TABLE deletes the table and every row in it, permanently, with no confirmation prompt. Always double-check you’re connected to the right database (SELECT DB_NAME();) before running DROP anywhere near a real environment.

    DDL Is Transactional โ€” Use It

    Because DDL participates in transactions in SQL Server, you can test a risky structural change safely:

    BEGIN TRAN;
    
    ALTER TABLE dbo.Employee DROP COLUMN salary;
    SELECT * FROM dbo.Employee; -- confirm it looks right
    
    ROLLBACK; -- changed your mind โ€” salary column is back, nothing happened
    -- or: COMMIT; -- to make it permanent
    Practice tip: Get in the habit of wrapping any ALTER/DROP you’re not 100% sure about in BEGIN TRAN, checking the result, then COMMIT or ROLLBACK. This one habit prevents most “oops, wrong table” DDL incidents.

    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 Fundamentals, coming soon on this site.