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