Track contentsDay 18 of 30

Day 18: User-defined functions

Intermediate2 hrs 30 minWorks in: SQL Server 2025, SSMS 22
On this page
  1. A. Lesson overview
  2. B. Theory explained clearly
  3. C. Dataset and starting state
  4. D. Worked examples
  5. E. Guided practical lab — A small function library with tests
  6. F. Independent practice
  7. G. Solutions and explanations
  8. H. Knowledge check
  9. I. Troubleshooting and cleanup
  10. J. Completion checklist, recap and references

By the end of this lesson you can

  • Create scalar, inline table-valued and multi-statement table-valued functions, and call each correctly.
  • List what functions are not allowed to do (change data, use dynamic SQL, call NEWID(), raise errors with THROW…).
  • Make a function deterministic and schema-bound, check it with OBJECTPROPERTY, and use it in a persisted computed column or CHECK constraint.
  • Explain scalar UDF inlining (SQL Server 2019+), check whether a function is inlineable, and describe what happens when it is not.
  • Explain why inline TVFs usually perform better than multi-statement TVFs, and rewrite one as the other.
  • Choose between a function, a view and a procedure for a given requirement.

A. Lesson overview

Week 3 — Data modification, programmability and error handling
Level Intermediate → Advanced
Estimated time 2 h 30 min essential + 30 min optional stretch
Prerequisites Day 10 (inline TVF primer, APPLY), Day 12 (Lab.Calendar), Day 17 (procedures)
Syllabus topic User-Defined Functions — scalar functions, table-valued functions, inline vs multi-statement

Activity breakdown

Activity Time
Part 1 — Three kinds of function and what they may not do 20 min
Part 2 — Scalar functions, determinism, SCHEMABINDING and inlining 25 min
Part 3 — Inline vs multi-statement table-valued functions 20 min
Part 4 — Functions vs procedures vs views: choosing 10 min
Worked examples 25 min
Guided lab 25 min
Independent practice 20 min
Knowledge check and wrap-up 5 min
Stretch challenge (optional) 30 min

Business scenario. The same calculations — line net value, price band, working days between two dates, “is this a valid email?” — are copied into dozens of queries, with small differences. The team wants a shared library of functions in the Lab schema, without making reports slow.


B. Theory explained clearly

Part 1 — Three kinds of function

Kind Returns Body Used in
Scalar One value BEGIN … RETURN expr; END — may declare variables, use IF Any expression: SELECT, WHERE, computed columns, CHECK
Inline table-valued (iTVF) A table One RETURN (SELECT …) FROM, JOIN, APPLY — like a parameterised view
Multi-statement table-valued (MSTVF) A table variable it fills RETURNS @t TABLE (…) AS BEGIN … INSERT @t …; RETURN; END FROM, JOIN, APPLY

Calling:

SELECT Lab.fn_LineNet(2, 189.00, 0.05);            -- scalar: always schema-qualified, with parentheses
SELECT * FROM Lab.fn_OrdersBetween('2025-12-01', '2026-01-01');   -- TVF: in FROM

Functions must not have side effects. Inside a function you cannot:

  • INSERT/UPDATE/DELETE a permanent or temporary table (an MSTVF may modify its own return table variable),
  • execute dynamic SQL or (non-extended) stored procedures,
  • use TRY…CATCH, THROW or RAISERROR,
  • call side-effecting or random built-ins such as NEWID() or RAND(),
  • change session settings or use transactions.

That restriction is what lets SQL Server call a function as part of a query, as many times as it needs, in any order.

Part 2 — Scalar functions

CREATE OR ALTER FUNCTION Lab.fn_LineNet
(
    @Quantity    int,
    @UnitPrice   decimal(10,2),
    @DiscountPct decimal(4,2)
)
RETURNS decimal(19,4)
WITH SCHEMABINDING, RETURNS NULL ON NULL INPUT
AS
BEGIN
    RETURN @Quantity * @UnitPrice * (1 - @DiscountPct);
END;
GO
Option Effect
WITH SCHEMABINDING Binds the function to the objects it references: they cannot be altered or dropped in ways that would break it. Also required for SQL Server to treat a function as deterministic
RETURNS NULL ON NULL INPUT If any argument is NULL, return NULL without running the body
RETURNS type The result is converted to this type. Choose it deliberately: a too-small type truncates or overflows

Determinism. A function is deterministic if it always returns the same result for the same input. Only deterministic, precise, schema-bound functions can be used in persisted computed columns and indexes. Check with SELECT OBJECTPROPERTY(OBJECT_ID('Lab.fn_LineNet'), 'IsDeterministic');.

The performance story: scalar UDF inlining

Before SQL Server 2019, a scalar function was executed once per row, as a separate call that the optimiser could not see into, and it prevented parallel plans. On large tables this was a classic cause of slow queries.

SQL Server 2019 and later (database compatibility level 150+) can inline many scalar functions: the function’s logic is rewritten into the calling query, as if you had typed the expression yourself. A function is inlineable only if it meets a list of conditions — for example, it must not use time-dependent functions such as GETDATE(), table variables, EXECUTE AS, or be referenced in a computed column or CHECK constraint, among others. You can check:

SELECT OBJECT_NAME(object_id) AS FunctionName, is_inlineable
FROM   sys.sql_modules
WHERE  object_id = OBJECT_ID('Lab.fn_LineNet');

is_inlineable = 1 means the function can be inlined; whether it is in a particular query can be seen in the execution plan (Day 22). Functions that read tables (like a working-days lookup) can often be inlined too, but complex ones may not be. When a scalar function is not inlined, prefer an inline TVF and APPLY for heavy use.

Part 3 — Inline vs multi-statement TVFs

-- Inline: a single SELECT
CREATE OR ALTER FUNCTION Lab.fn_OrdersBetween (@From date, @To date)
RETURNS TABLE
AS
RETURN
(
    SELECT o.OrderID, o.CustomerID, o.OrderDate, SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS OrderNet
    FROM   Sales.SalesOrder AS o JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
    WHERE  o.OrderStatus <> 'Cancelled' AND o.OrderDate >= @From AND o.OrderDate < @To
    GROUP BY o.OrderID, o.CustomerID, o.OrderDate
);
GO

-- Multi-statement: fills a table variable
CREATE OR ALTER FUNCTION Lab.fn_OrdersBetween_MS (@From date, @To date)
RETURNS @Result TABLE (OrderID int PRIMARY KEY, CustomerID int, OrderDate date, OrderNet decimal(19,4))
AS
BEGIN
    INSERT INTO @Result (OrderID, CustomerID, OrderDate, OrderNet)
    SELECT … same query … ;
    RETURN;
END;
GO
Inline TVF Multi-statement TVF
How SQL Server runs it Expanded into the calling query, like a view; filters and joins from the caller are optimised together Runs as a separate step that materialises all rows into a table variable, then the caller reads them
Caller’s WHERE Can be pushed into the function’s query (e.g. use an index) Applied only after the whole function result is built
Row estimates Normal statistics Limited; SQL Server 2017+ interleaved execution can use the actual row count in some cases, older versions guess a fixed number
Logic allowed One SELECT (CTEs allowed) Several statements, variables, IF, loops
Use when Almost always Logic genuinely cannot be one query

On today’s small tables both are instant; Day 22’s larger data set is where the difference becomes visible. The rule of thumb stands regardless: prefer inline.

Part 4 — Function, view or procedure?

Need Use
Reusable expression inside queries (one value per row) Scalar function — or simply a computed column or an APPLY (VALUES …)
Reusable query, no parameters View
Reusable query with parameters, to be joined/filtered further Inline TVF
Change data, manage transactions, return several result sets, dynamic SQL Stored procedure

C. Dataset and starting state

Dataset Shared synthetic dataset plus the Day 12 objects
Database RetailLab
Read from Sales.SalesOrder, Sales.SalesOrderLine, Sales.Payment, Inventory.Product, Lab.Calendar
Changed Lab.Product (copy, for the computed-column task), Lab.ContactSignup (stretch)
Permissions CREATE FUNCTION, CREATE TABLE, ALTER on schema Lab; REFERENCES on referenced objects for SCHEMABINDING (you have it as owner)
Objects created Functions Lab.fn_LineNet, Lab.fn_PriceBand, Lab.fn_OrdersBetween, Lab.fn_OrdersBetween_MS, Lab.fn_WorkingDaysBetween, Lab.tvf_WorkingDaysBetween, Lab.fn_TopProducts, Lab.fn_CustomerTimeline_MS, Lab.fn_CustomerTimeline, Lab.fn_MarginPct, Lab.fn_IsValidEmail

Preparation. Lab.Calendar must exist — if SELECT COUNT(*) FROM Lab.Calendar; fails, run scripts/lessons/day12_calendar_and_temporal.sql.

Validation. Expected: 1096, 30, 170.

USE RetailLab;
GO
SELECT  (SELECT COUNT(*) FROM Lab.Calendar)       AS CalendarDays,
        (SELECT COUNT(*) FROM Inventory.Product)  AS Products,
        (SELECT compatibility_level FROM sys.databases WHERE name = DB_NAME()) AS CompatLevel;

D. Worked examples

Worked example 1 — A scalar function and its properties

CREATE OR ALTER FUNCTION Lab.fn_LineNet
(
    @Quantity    int,
    @UnitPrice   decimal(10,2),
    @DiscountPct decimal(4,2)
)
RETURNS decimal(19,4)
WITH SCHEMABINDING, RETURNS NULL ON NULL INPUT
AS
BEGIN
    RETURN @Quantity * @UnitPrice * (1 - @DiscountPct);
END;
GO

SELECT  Lab.fn_LineNet(2, 189.00, 0.05)                           AS OneLine,          -- 359.1000
        Lab.fn_LineNet(NULL, 189.00, 0.05)                        AS NullInput,        -- NULL
        OBJECTPROPERTY(OBJECT_ID('Lab.fn_LineNet'), 'IsDeterministic') AS IsDeterministic,
        (SELECT is_inlineable FROM sys.sql_modules WHERE object_id = OBJECT_ID('Lab.fn_LineNet')) AS IsInlineable;

SELECT CAST(SUM(Lab.fn_LineNet(Quantity, UnitPrice, DiscountPct)) AS decimal(12,2)) AS AllLines
FROM   Sales.SalesOrderLine;

Expected result: 359.1000, NULL, 1, 1; then 124384.43 (the validation checksum 124384.4345, rounded). Remove WITH SCHEMABINDING and re-create: IsDeterministic becomes 0.

Worked example 2 — Inline and multi-statement versions of the same TVF

CREATE OR ALTER FUNCTION Lab.fn_OrdersBetween (@From date, @To date)
RETURNS TABLE
AS
RETURN
(
    SELECT  o.OrderID, o.CustomerID, o.OrderDate,
            SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS OrderNet
    FROM    Sales.SalesOrder     AS o
    JOIN    Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
    WHERE   o.OrderStatus <> 'Cancelled'
      AND   o.OrderDate >= @From AND o.OrderDate < @To
    GROUP BY o.OrderID, o.CustomerID, o.OrderDate
);
GO

CREATE OR ALTER FUNCTION Lab.fn_OrdersBetween_MS (@From date, @To date)
RETURNS @Result TABLE
(
    OrderID    int           NOT NULL PRIMARY KEY,
    CustomerID int           NOT NULL,
    OrderDate  date          NOT NULL,
    OrderNet   decimal(19,4) NOT NULL
)
AS
BEGIN
    INSERT INTO @Result (OrderID, CustomerID, OrderDate, OrderNet)
    SELECT  o.OrderID, o.CustomerID, o.OrderDate,
            SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct))
    FROM    Sales.SalesOrder     AS o
    JOIN    Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
    WHERE   o.OrderStatus <> 'Cancelled'
      AND   o.OrderDate >= @From AND o.OrderDate < @To
    GROUP BY o.OrderID, o.CustomerID, o.OrderDate;
    RETURN;
END;
GO

SELECT 'Inline' AS Version, COUNT(*) AS Orders, CAST(SUM(OrderNet) AS decimal(12,2)) AS NetSales
FROM   Lab.fn_OrdersBetween('2025-01-01', '2026-01-01')
UNION ALL
SELECT 'Multi-statement', COUNT(*), CAST(SUM(OrderNet) AS decimal(12,2))
FROM   Lab.fn_OrdersBetween_MS('2025-01-01', '2026-01-01');

Expected result: both rows show 198 and 63741.94. Same answer — the difference is how SQL Server gets there. Turn on Include Actual Execution Plan (Ctrl + M) and run each SELECT separately: the inline version shows the underlying tables directly; the multi-statement version shows a Table Valued Function operator feeding a table scan of its result. Day 22 explains how to read these plans.

Worked example 3 — A scalar function that reads a table

CREATE OR ALTER FUNCTION Lab.fn_WorkingDaysBetween (@Start date, @End date)
RETURNS int
WITH SCHEMABINDING
AS
BEGIN
    RETURN (SELECT COUNT(*)
            FROM   Lab.Calendar
            WHERE  CalendarDate >  @Start
              AND  CalendarDate <= @End
              AND  IsWorkingDay = 1);
END;
GO

SELECT Lab.fn_WorkingDaysBetween('2025-12-19', '2025-12-29') AS WorkingDays,   -- 4
       (SELECT is_inlineable FROM sys.sql_modules
        WHERE object_id = OBJECT_ID('Lab.fn_WorkingDaysBetween')) AS IsInlineable;

Expected result: 4 (22, 23, 24 and 29 December; the 25th and 26th are bank holidays; the 20th, 21st, 27th and 28th are weekends) and IsInlineable should be 1 (a single SELECT with an aggregate is within the inlining rules). Because of SCHEMABINDING, DROP TABLE Lab.Calendar now fails until the function is dropped — protection against silent breakage. This also means the Day 12 script cannot be re-run while this function exists; section I gives the cleanup order.

Worked example 4 — Scalar function or APPLY?

CREATE OR ALTER FUNCTION Lab.tvf_WorkingDaysBetween (@Start date, @End date)
RETURNS TABLE
WITH SCHEMABINDING
AS
RETURN (SELECT COUNT(*) AS WorkingDays
        FROM   Lab.Calendar
        WHERE  CalendarDate > @Start AND CalendarDate <= @End AND IsWorkingDay = 1);
GO

SELECT  COUNT(*)                                                              AS ShippedOrders,
        SUM(CASE WHEN Lab.fn_WorkingDaysBetween(o.OrderDate, o.ShippedDate) <= 2 THEN 1 ELSE 0 END) AS WithinSLA_Scalar,
        SUM(CASE WHEN w.WorkingDays <= 2 THEN 1 ELSE 0 END)                    AS WithinSLA_TVF
FROM    Sales.SalesOrder AS o
CROSS APPLY Lab.tvf_WorkingDaysBetween(o.OrderDate, o.ShippedDate) AS w
WHERE   o.SalesChannel = 'Online' AND o.ShippedDate IS NOT NULL
  AND   o.OrderDate >= '2025-01-01' AND o.OrderDate < '2026-01-01';

Expected result: 106, 88, 88 — Day 12’s SLA result, now from reusable functions. Both approaches agree; the inline TVF is guaranteed to be optimised as part of the query, while the scalar version relies on inlining.


E. Guided practical lab — A small function library with tests

Build three functions and a test query that proves they work, including edge cases.

Step 1 — Price band (scalar, deterministic)

  1. Do: Create the function.
  2. Run:
    USE RetailLab;
    GO
    
    CREATE OR ALTER FUNCTION Lab.fn_PriceBand (@ListPrice decimal(10,2))
    RETURNS varchar(10)
    WITH SCHEMABINDING
    AS
    BEGIN
        RETURN CASE
                   WHEN @ListPrice IS NULL THEN NULL
                   WHEN @ListPrice < 50    THEN 'Budget'
                   WHEN @ListPrice < 150   THEN 'Mid-range'
                   ELSE                         'Premium'
               END;
    END;
    GO
  3. Observe: Commands completed successfully.
  4. Why it matters: Day 1’s CASE, Day 8’s lookup table and now a function — three ways to encode one rule. A function guarantees every report uses the same boundaries.
  5. Confirm: SELECT OBJECTPROPERTY(OBJECT_ID('Lab.fn_PriceBand'), 'IsDeterministic'); → 1.

Step 2 — Test with edge cases

  1. Do: Test boundaries and NULL in one query.
  2. Run:
    SELECT  v.TestValue, Lab.fn_PriceBand(v.TestValue) AS Band, v.Expected,
            CASE WHEN Lab.fn_PriceBand(v.TestValue) = v.Expected
                   OR (Lab.fn_PriceBand(v.TestValue) IS NULL AND v.Expected IS NULL)
                 THEN 'PASS' ELSE 'FAIL' END AS Result
    FROM   (VALUES (CAST(49.99 AS decimal(10,2)), 'Budget'),
                   (50.00,  'Mid-range'),
                   (149.99, 'Mid-range'),
                   (150.00, 'Premium'),
                   (NULL,   NULL)) AS v (TestValue, Expected);
  3. Observe: 5 rows, all PASS.
  4. Why it matters: Boundaries are where business rules break. A tiny test table makes the rule explicit and repeatable.
  5. Confirm: no FAIL.

Step 3 — Use it on real data

  1. Do: Count products per band.
  2. Run:
    SELECT Lab.fn_PriceBand(ListPrice) AS Band, COUNT(*) AS Products
    FROM   Inventory.Product
    GROUP BY Lab.fn_PriceBand(ListPrice)
    ORDER BY MIN(ListPrice);
  3. Observe: Budget 8, Mid-range 10, Premium 12 — the same as Day 1.
  4. Why it matters: A function call can be grouped on like any expression.
  5. Confirm: 30 products in total.

Step 4 — An inline TVF for “top N products in a year”

  1. Do: Create a parameterised top-N function and apply it to several years at once.

  2. Run:

    CREATE OR ALTER FUNCTION Lab.fn_TopProducts (@Year int, @N int)
    RETURNS TABLE
    AS
    RETURN
    (
        SELECT TOP (@N)
                p.ProductCode, p.ProductName,
                SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS NetSales
        FROM    Sales.SalesOrder     AS o
        JOIN    Sales.SalesOrderLine AS l ON l.OrderID   = o.OrderID
        JOIN    Inventory.Product    AS p ON p.ProductID = l.ProductID
        WHERE   o.OrderStatus <> 'Cancelled'
          AND   o.OrderDate >= DATEFROMPARTS(@Year, 1, 1) AND o.OrderDate < DATEFROMPARTS(@Year + 1, 1, 1)
        GROUP BY p.ProductCode, p.ProductName
        ORDER BY NetSales DESC, p.ProductCode
    );
    GO
    
    SELECT  y.Yr, t.ProductCode, t.ProductName, CAST(t.NetSales AS decimal(12,2)) AS NetSales
    FROM   (VALUES (2024), (2025)) AS y (Yr)
    CROSS APPLY Lab.fn_TopProducts(y.Yr, 3) AS t
    ORDER BY y.Yr, t.NetSales DESC;
  3. Observe:

    Yr ProductCode ProductName NetSales
    2024 TNT-002 Summit 4-Person Tent 7119.60
    2024 TNT-004 Classic Canvas Bell Tent 6768.70
    2024 NAV-002 Handheld GPS Unit 5516.55
    2025 TNT-002 Summit 4-Person Tent 8777.35
    2025 TNT-003 Ultralight Solo Shelter 5241.45
    2025 SLP-002 Arctic Down Sleeping Bag 4903.60
  4. Why it matters: An inline TVF + CROSS APPLY over a list of parameter values replaces a loop of procedure calls. The bell tent was 2024’s number two product — and discontinued at year end.

  5. Confirm: 6 rows.


F. Independent practice

Task 1 — Margin percentage function

Create Lab.fn_MarginPct(@ListPrice decimal(10,2), @StandardCost decimal(10,2)) RETURNS decimal(5,1) — schema-bound, returning NULL when the price is 0 or NULL. Use it to list the three highest-margin products.

Success criteria: NAV-003 66.6, NAV-001 64.0, BSL-003 62.5. IsDeterministic = 1.

Task 2 — Persisted computed column

Copy Inventory.Product to Lab.Product, then add MarginPct AS Lab.fn_MarginPct(ListPrice, StandardCost) PERSISTED. Query it for TNT-004 and NAV-002. Then try to ALTER the function to remove SCHEMABINDING and observe what happens.

Success criteria: TNT-004 46.6, NAV-002 36.5. Altering the function fails because a persisted computed column depends on it (Cannot ALTER ‘Lab.fn_MarginPct’ because it is being referenced by object ‘Product’.).

Task 3 — Customer timeline: multi-statement, then inline

Write Lab.fn_CustomerTimeline_MS(@CustomerID int) returning EventDate, EventType (Order / Payment), OrderID and Amount — orders at amount due (net rounded to pence plus shipping), and payments. Then write the inline equivalent Lab.fn_CustomerTimeline using UNION ALL. Compare them for customer 35 with EXCEPT in both directions.

Success criteria: customer 35: 4 rows — order and payment for 1362 (33.95, 21 Dec 2025) and for 1364 (476.00, 22 Dec 2025). Both EXCEPTs return 0 rows.

Task 4 — A function that is not inlineable (challenging)

Create Lab.fn_DaysSinceOrderToday(@OrderDate date) RETURNS int that uses GETDATE(). Check is_inlineable. Then create an equivalent that takes the reference date as a parameter, Lab.fn_DaysBetween(@From date, @To date), and check again. Explain the difference in one sentence.

Success criteria: the GETDATE() version reports is_inlineable = 0; the parameterised version reports 1.

Stretch challenge — A validation function in a CHECK constraint

Create Lab.fn_IsValidEmail(@Email nvarchar(255)) RETURNS bit using REGEXP_LIKE. Create Lab.ContactSignup (SignupID int IDENTITY PRIMARY KEY, Email nvarchar(255) NOT NULL, CONSTRAINT CK_ContactSignup_Email CHECK (Lab.fn_IsValidEmail(Email) = 1)). Insert the 15 import emails one at a time in a loop or individually, counting successes and failures.

Success criteria: 12 rows accepted (the 14 non-NULL emails minus 2 invalid); 2 inserts fail with a CHECK constraint violation; the NULL email fails the NOT NULL rule. Note that a function used in a CHECK constraint is never inlined there, even if is_inlineable shows 1.

Hint

REGEXP_LIKE returns a Boolean, which can’t be returned directly: RETURN CASE WHEN REGEXP_LIKE(LOWER(TRIM(@Email)), '^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$') THEN 1 ELSE 0 END;. Use a set-based test instead of single inserts: SELECT … , Lab.fn_IsValidEmail(Email) FROM Staging.CustomerImport shows what would pass.


G. Solutions and explanations

Show solution 1
CREATE OR ALTER FUNCTION Lab.fn_MarginPct (@ListPrice decimal(10,2), @StandardCost decimal(10,2))
RETURNS decimal(5,1)
WITH SCHEMABINDING
AS
BEGIN
    RETURN CASE WHEN @ListPrice IS NULL OR @ListPrice = 0 OR @StandardCost IS NULL THEN NULL
                ELSE CAST(100.0 * (@ListPrice - @StandardCost) / @ListPrice AS decimal(5,1)) END;
END;
GO

SELECT TOP (3) ProductCode, Lab.fn_MarginPct(ListPrice, StandardCost) AS MarginPct
FROM   Inventory.Product
ORDER BY MarginPct DESC, ProductCode;

Reasoning. The CASE handles NULL and zero before dividing. CAST to decimal(5,1) rounds, making the function’s output type explicit.

Show solution 2
DROP TABLE IF EXISTS Lab.Product;
SELECT * INTO Lab.Product FROM Inventory.Product;
ALTER TABLE Lab.Product ADD CONSTRAINT PK_LabProduct PRIMARY KEY (ProductID);

ALTER TABLE Lab.Product ADD MarginPct AS Lab.fn_MarginPct(ListPrice, StandardCost) PERSISTED;

SELECT ProductCode, MarginPct FROM Lab.Product WHERE ProductCode IN ('TNT-004', 'NAV-002') ORDER BY ProductCode;

GO

-- Try to change the function
CREATE OR ALTER FUNCTION Lab.fn_MarginPct (@ListPrice decimal(10,2), @StandardCost decimal(10,2))
RETURNS decimal(5,1)
AS
BEGIN
    RETURN NULL;
END;
GO

Result: NAV-002 36.5, TNT-004 46.6; the ALTER fails because Lab.Product depends on the function.

Reasoning. PERSISTED stores the computed value in the row, which requires a deterministic expression — hence SCHEMABINDING. To change the function you would first drop the computed column. Day 26 covers computed columns in depth.

Show solution 3
CREATE OR ALTER FUNCTION Lab.fn_CustomerTimeline_MS (@CustomerID int)
RETURNS @T TABLE (EventDate date, EventType varchar(10), OrderID int, Amount decimal(12,2))
AS
BEGIN
    INSERT INTO @T (EventDate, EventType, OrderID, Amount)
    SELECT  o.OrderDate, 'Order', o.OrderID,
            ROUND(SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)), 2) + o.ShippingCost
    FROM    Sales.SalesOrder AS o
    JOIN    Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
    WHERE   o.CustomerID = @CustomerID
    GROUP BY o.OrderDate, o.OrderID, o.ShippingCost;

    INSERT INTO @T (EventDate, EventType, OrderID, Amount)
    SELECT  p.PaymentDate, 'Payment', p.OrderID, p.Amount
    FROM    Sales.Payment AS p
    JOIN    Sales.SalesOrder AS o ON o.OrderID = p.OrderID
    WHERE   o.CustomerID = @CustomerID;

    RETURN;
END;
GO

CREATE OR ALTER FUNCTION Lab.fn_CustomerTimeline (@CustomerID int)
RETURNS TABLE
AS
RETURN
(
    SELECT  o.OrderDate AS EventDate, CAST('Order' AS varchar(10)) AS EventType, o.OrderID,
            CAST(ROUND(SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)), 2) + o.ShippingCost AS decimal(12,2)) AS Amount
    FROM    Sales.SalesOrder AS o
    JOIN    Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
    WHERE   o.CustomerID = @CustomerID
    GROUP BY o.OrderDate, o.OrderID, o.ShippingCost
    UNION ALL
    SELECT  p.PaymentDate, 'Payment', p.OrderID, p.Amount
    FROM    Sales.Payment AS p
    JOIN    Sales.SalesOrder AS o ON o.OrderID = p.OrderID
    WHERE   o.CustomerID = @CustomerID
);
GO

SELECT * FROM Lab.fn_CustomerTimeline(35) ORDER BY EventDate, EventType, OrderID;

SELECT * FROM Lab.fn_CustomerTimeline_MS(35) EXCEPT SELECT * FROM Lab.fn_CustomerTimeline(35);
SELECT * FROM Lab.fn_CustomerTimeline(35)    EXCEPT SELECT * FROM Lab.fn_CustomerTimeline_MS(35);

Reasoning. The multi-statement version needed two INSERTs; the inline version expresses the same with UNION ALL. Most “I need several steps” functions can be rewritten as one query with CTEs and set operators — and then gain the inline performance benefits. The CASTs make both versions return identical types, so EXCEPT compares like with like.

Show solution 4
CREATE OR ALTER FUNCTION Lab.fn_DaysSinceOrderToday (@OrderDate date)
RETURNS int
AS
BEGIN
    RETURN DATEDIFF(DAY, @OrderDate, CAST(GETDATE() AS date));
END;
GO

CREATE OR ALTER FUNCTION Lab.fn_DaysBetween (@From date, @To date)
RETURNS int
WITH SCHEMABINDING
AS
BEGIN
    RETURN DATEDIFF(DAY, @From, @To);
END;
GO

SELECT OBJECT_NAME(object_id) AS FunctionName, is_inlineable
FROM   sys.sql_modules
WHERE  object_id IN (OBJECT_ID('Lab.fn_DaysSinceOrderToday'), OBJECT_ID('Lab.fn_DaysBetween'));

Result: fn_DaysSinceOrderToday 0, fn_DaysBetween 1.

Reasoning. Scalar UDF inlining excludes functions that call time-dependent built-ins such as GETDATE(). Passing “today” as a parameter makes the function inlineable, deterministic and testable — and is how this course handles the reference date throughout.

Show the stretch solution
CREATE OR ALTER FUNCTION Lab.fn_IsValidEmail (@Email nvarchar(255))
RETURNS bit
WITH SCHEMABINDING
AS
BEGIN
    RETURN CASE WHEN REGEXP_LIKE(LOWER(TRIM(@Email)), '^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$')
                THEN 1 ELSE 0 END;
END;
GO

DROP TABLE IF EXISTS Lab.ContactSignup;
CREATE TABLE Lab.ContactSignup
(
    SignupID int IDENTITY(1,1) NOT NULL PRIMARY KEY,
    Email    nvarchar(255)     NOT NULL,
    CONSTRAINT CK_ContactSignup_Email CHECK (Lab.fn_IsValidEmail(Email) = 1)
);
GO

-- Set-based preview of what would pass
SELECT ImportRowID, Email, Lab.fn_IsValidEmail(Email) AS IsValid
FROM   Staging.CustomerImport
ORDER BY ImportRowID;

-- One insert per row, counting outcomes (errors are handled properly on Day 20)
DECLARE @id int = 1, @ok int = 0, @failed int = 0, @e nvarchar(255);
WHILE @id <= 15
BEGIN
    SELECT @e = Email FROM Staging.CustomerImport WHERE ImportRowID = @id;
    BEGIN TRY
        INSERT INTO Lab.ContactSignup (Email) VALUES (@e);
        SET @ok += 1;
    END TRY
    BEGIN CATCH
        SET @failed += 1;
    END CATCH;
    SET @id += 1;
END;
SELECT @ok AS Accepted, @failed AS Rejected;

Result: preview shows IsValid 0 for rows 7, 12 (NULL → CASE gives 0) and 15; the loop reports Accepted 12, Rejected 3.

Reasoning. The TRY…CATCH (Day 20) lets the loop continue after each failure. A CHECK constraint enforces the rule for every future insert, not just this load; the price is that the function is evaluated for each inserted or updated row and cannot be inlined. Many teams prefer to validate in the load process and keep constraints simple — a design decision to make consciously.


H. Knowledge check

  1. Understanding. Why can’t a function INSERT into a permanent table?

  2. Query interpretation. A report calls a scalar function that reads Sales.Payment for each of 3 million order rows. Before SQL Server 2019 this was very slow. Why, and what are two ways to improve it?

  3. Debugging. ALTER TABLE … ADD Margin AS Lab.fn_X(ListPrice, StandardCost) PERSISTED; fails with a message about the column being non-deterministic. What do you check?

  4. Understanding. Name two reasons an inline TVF usually outperforms a multi-statement TVF.

  5. Debugging. DROP TABLE Lab.Calendar; fails: Cannot DROP TABLE ‘Lab.Calendar’ because it is being referenced by object ‘fn_WorkingDaysBetween’. Why, and what is the correct order of operations?

Show the answers
  1. Functions must be free of side effects so SQL Server can call them any number of times, in any order, as part of a query. Data changes belong in procedures.

  2. The function ran once per row as a separate call with its own query, invisible to the optimiser, and blocked parallelism. Improvements: rely on scalar UDF inlining (2019+, if the function qualifies); or rewrite as an inline TVF used with APPLY (or as a join/aggregate in the query itself).

  3. That the function is created WITH SCHEMABINDING, uses only deterministic logic (no GETDATE(), no non-deterministic functions) and precise types (no float if the column is to be indexed). Check OBJECTPROPERTY(…, 'IsDeterministic').

  4. It is expanded into the calling query, so filters and joins are optimised together (and indexes can be used); and it has normal cardinality estimates, while a multi-statement TVF materialises all rows into a table variable first with limited estimates.

  5. SCHEMABINDING prevents dropping objects a schema-bound function depends on. Drop (or alter without schema binding) the dependent function first, then the table — and recreate the function after re-creating the table.


I. Troubleshooting and cleanup

Error or symptom Cause Fix
‘X’ is not a recognized built-in function name. Scalar function called without the schema Lab.fn_X(…)
Invalid use of a side-effecting operator ‘INSERT’ within a function. Data change in a function Use a procedure
Invalid use of a side-effecting operator ‘newid’ within a function. NEWID()/RAND() in a function Pass the value in as a parameter
Cannot schema bind function … because name ‘Calendar’ is invalid for schema binding. Object not referenced with a two-part name Lab.Calendar
Cannot DROP/ALTER … because it is being referenced by object … Schema-bound dependency Drop dependants first
Computed column … cannot be persisted because the column is non-deterministic. Function not schema-bound or uses non-deterministic logic Add SCHEMABINDING; remove non-deterministic calls
Report slow when a scalar function is used on many rows Function not inlined Check is_inlineable; rewrite as inline TVF + APPLY

Cleanup. Drop today’s objects in dependency order. This is required before you re-run the Day 12 script in future (it drops Lab.Calendar):

DROP TABLE    IF EXISTS Lab.ContactSignup;          -- depends on fn_IsValidEmail
DROP TABLE    IF EXISTS Lab.Product;                -- depends on fn_MarginPct
DROP FUNCTION IF EXISTS Lab.fn_IsValidEmail, Lab.fn_MarginPct, Lab.fn_LineNet, Lab.fn_PriceBand,
                        Lab.fn_OrdersBetween, Lab.fn_OrdersBetween_MS, Lab.fn_WorkingDaysBetween,
                        Lab.tvf_WorkingDaysBetween, Lab.fn_TopProducts, Lab.fn_CustomerTimeline_MS,
                        Lab.fn_CustomerTimeline, Lab.fn_DaysSinceOrderToday, Lab.fn_DaysBetween;

Keep the Day 12 objects and the Day 17 procedures. Core data is unchanged.


J. Completion checklist, recap and references

Completion checklist

  • Worked example 1 showed IsDeterministic 1 and is_inlineable 1 for Lab.fn_LineNet.
  • Inline and multi-statement versions both returned 198 orders and 63741.94.
  • Your price-band tests all passed and the bands counted 8 / 10 / 12.
  • Lab.fn_TopProducts returned the six rows in the lab.
  • Tasks 1–4 match the success criteria; you ran the cleanup in order.

Recap

  • Scalar functions return a value; inline TVFs are parameterised queries; multi-statement TVFs fill a table variable.
  • Functions can’t have side effects: no data changes, dynamic SQL, THROW, NEWID().
  • SCHEMABINDING protects dependencies and is needed for determinism; deterministic functions can drive persisted computed columns.
  • SQL Server 2019+ can inline qualifying scalar functions; check sys.sql_modules.is_inlineable. Time-dependent functions and use in constraints/computed columns prevent inlining.
  • Prefer inline TVFs over multi-statement TVFs; most multi-step logic fits in one query.

Next: Day 19 — Triggers: AFTER and INSTEAD OF triggers, the inserted/deleted tables, and set-based logic that handles multi-row changes correctly.

References (Microsoft Learn)