Deep dive · Query skill

Advanced T-SQL deep dive

The exam rarely asks you to write a query from a blank page; it shows you one and asks what it returns, why it fails, or which line fixes it. This page is the reading list for that: every family the outline names, with the defaults and edge cases that turn into answer options.

Skills measured as of March 12, 2026

Read the query in logical order

Most "why does this fail" items come down to logical processing order: a window function or a column alias cannot be used in WHERE, because WHERE runs before SELECT.

flowchart TB
  subgraph ROWS["Choose the rows"]
    direction LR
    FROM["1 · FROM<br/>JOIN · APPLY"] --> WHERE["2 · WHERE"]
    WHERE --> GROUP["3 · GROUP BY"]
    GROUP --> HAVING["4 · HAVING"]
  end
  subgraph SHAPE["Shape the result"]
    direction LR
    SELECT["5 · SELECT<br/>window functions here"] --> ORDER["6 · ORDER BY"]
    ORDER --> TOP["7 · TOP · OFFSET"]
  end
  ROWS ~~~ SHAPE
Logical query processing order. Window functions are computed in the SELECT phase, after WHERE, GROUP BY and HAVING — which is why ranking filters need a CTE or derived table.

Common table expressions

A CTE names an intermediate result for one statement. It improves readability and lets one step be referenced several times, but it is not materialized — referencing it twice can evaluate it twice. A recursive CTE walks hierarchies and graphs-by-key: an anchor member, UNION ALL, and a member that joins back to the CTE.

T-SQL
-- Applies to: all four platforms
-- A CTE names a step; it is not materialized, and it lives for one statement.
WITH OrderTotals AS (
    SELECT CustomerID, SUM(Total) AS Spend
    FROM dbo.OrderHeader
    WHERE OrderDate >= '2026-01-01'
    GROUP BY CustomerID
)
SELECT c.Name, t.Spend
FROM OrderTotals AS t
JOIN dbo.Customer AS c ON c.CustomerID = t.CustomerID
WHERE t.Spend > 10000;

-- Recursive CTE: anchor member UNION ALL recursive member
WITH OrgChart AS (
    SELECT EmployeeID, ManagerID, Name, 0 AS Level
    FROM dbo.Employee
    WHERE ManagerID IS NULL                               -- anchor: the root
    UNION ALL
    SELECT e.EmployeeID, e.ManagerID, e.Name, oc.Level + 1
    FROM dbo.Employee AS e
    JOIN OrgChart AS oc ON e.ManagerID = oc.EmployeeID     -- recursive member
)
SELECT * FROM OrgChart
OPTION (MAXRECURSION 200);                                 -- default 100; 0 = unlimited

CTE gotchas

  • The statement before WITH must end with a semicolon.
  • A recursive CTE stops with error 530 after 100 levels unless you set OPTION (MAXRECURSION n) on the outer statement.
  • A recursive member cannot use GROUP BY, TOP, aggregates or outer joins to the CTE.

Window functions

A window function computes over a set of rows related to the current row without collapsing them. PARTITION BY resets the window, ORDER BY orders it, and the frame (ROWS or RANGE BETWEEN …) limits which rows an aggregate sees.

T-SQL
-- Applies to: all four platforms (WINDOW clause and IGNORE NULLS: SQL Server 2022+ and Azure SQL)
SELECT  CustomerID, OrderNo, OrderDate, Total,
        ROW_NUMBER() OVER w                AS rn,          -- 1, 2, 3, 4 — unique
        RANK()       OVER w                AS rnk,         -- 1, 2, 2, 4 — gaps after ties
        DENSE_RANK() OVER w                AS drnk,        -- 1, 2, 2, 3 — no gaps
        NTILE(4)     OVER (ORDER BY Total) AS quartile,
        LAG(Total)  OVER (PARTITION BY CustomerID ORDER BY OrderDate) AS prev_total,
        LEAD(OrderDate, 1, '9999-12-31') OVER (PARTITION BY CustomerID ORDER BY OrderDate) AS next_order,
        SUM(Total) OVER (PARTITION BY CustomerID ORDER BY OrderDate
                         ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total,
        AVG(Total) OVER (PARTITION BY CustomerID ORDER BY OrderDate
                         ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)         AS moving_avg_3,
        LAST_VALUE(Total) OVER (PARTITION BY CustomerID ORDER BY OrderDate
                         ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS last_total
FROM dbo.OrderHeader
WINDOW w AS (PARTITION BY CustomerID ORDER BY Total DESC);

-- Top 3 orders per customer: window functions can't go in WHERE, so filter in an outer query
WITH ranked AS (
    SELECT *, ROW_NUMBER() OVER (PARTITION BY CustomerID ORDER BY Total DESC) AS rn
    FROM dbo.OrderHeader
)
SELECT * FROM ranked WHERE rn <= 3;
FamilyFunctionsWatch for
RankingROW_NUMBER, RANK, DENSE_RANK, NTILETies: ROW_NUMBER is arbitrary among equals unless the ORDER BY is unique
OffsetLAG, LEAD, FIRST_VALUE, LAST_VALUELAST_VALUE with the default frame returns the current row — widen the frame to UNBOUNDED FOLLOWING
AggregateSUM, AVG, COUNT, MIN, MAX with OVERWith ORDER BY and no frame, the default is RANGE UNBOUNDED PRECEDING: ties share a running total and it spills to tempdb — use ROWS
DistributionPERCENT_RANK, CUME_DIST, PERCENTILE_CONT, PERCENTILE_DISCPERCENTILE_* use WITHIN GROUP (ORDER BY …) plus OVER (PARTITION BY …)

JSON functions

The outline names six functions; the family is larger, and the exam mixes them.

TaskFunctionPlatform line
Build an object or array from valuesJSON_OBJECT('key': value, …), JSON_ARRAY(…)SQL Server 2022+, Azure SQL, Fabric
Aggregate rows into an array or objectJSON_ARRAYAGG(… ORDER BY …), JSON_OBJECTAGG(key : value)SQL Server 2025+, Azure SQL, Fabric
Shape a result setFOR JSON PATH / AUTO, ROOT, WITHOUT_ARRAY_WRAPPER, INCLUDE_NULL_VALUESAll
Read one scalarJSON_VALUE (returns NULL for an object or array)All
Read an object or arrayJSON_QUERY (with WITH ARRAY WRAPPER in SQL Server 2025)All
Shred into rowsOPENJSON, with a WITH schema and AS JSON for nested partsAll
Test containmentJSON_CONTAINS(doc, value, path [, search_mode]) → 1, 0 or NULLSQL Server 2025+, Azure SQL, Fabric
Test, changeISJSON, JSON_PATH_EXISTS, JSON_MODIFY (or the json type's modify method)All (modify: json type only)
T-SQL
-- Build JSON
SELECT JSON_OBJECT('id': c.CustomerID, 'name': c.Name, 'vip': CAST(c.IsVip AS bit)
                   ABSENT ON NULL) AS customer_json            -- default for JSON_OBJECT is NULL ON NULL
FROM dbo.Customer AS c;

SELECT JSON_ARRAY(1, N'two', NULL, 4) AS arr;                -- default ABSENT ON NULL → [1,"two",4]

-- Aggregate rows into JSON (SQL Server 2025+, Azure SQL, Fabric)
SELECT c.CustomerID,
       JSON_ARRAYAGG(o.OrderNo ORDER BY o.OrderDate)         AS order_numbers,
       JSON_OBJECTAGG(CAST(o.OrderNo AS nvarchar(20)) : o.Total) AS totals_by_order
FROM dbo.Customer AS c
JOIN dbo.OrderHeader AS o ON o.CustomerID = c.CustomerID
GROUP BY c.CustomerID;

-- Shape a whole result set as JSON
SELECT TOP (3) o.OrderNo AS [order.no], o.OrderDate AS [order.date], c.Name AS [customer.name]
FROM dbo.OrderHeader AS o JOIN dbo.Customer AS c ON c.CustomerID = o.CustomerID
FOR JSON PATH, ROOT('orders'), INCLUDE_NULL_VALUES;           -- dot aliases nest objects
T-SQL
DECLARE @doc json = N'{
  "order": { "no": 1001, "status": "shipped",
             "lines": [ {"sku": "A-1", "qty": 2}, {"sku": "B-7", "qty": 1} ] },
  "tags": ["priority", "gift"] }';

SELECT JSON_VALUE(@doc, '$.order.status')           AS status,       -- scalar → nvarchar(4000)
       JSON_QUERY(@doc, '$.order.lines')            AS lines,        -- object or array
       JSON_PATH_EXISTS(@doc, '$.order.coupon')     AS has_coupon,   -- 0
       JSON_CONTAINS(@doc, 'gift', '$.tags[*]')     AS is_gift,      -- 1 (SQL Server 2025+, Azure SQL, Fabric)
       JSON_VALUE(@doc, 'strict $.order.missing')   AS boom;         -- strict: error instead of NULL

-- Shred an array into rows with a typed schema
SELECT l.sku, l.qty
FROM OPENJSON(@doc, '$.order.lines')
     WITH (sku nvarchar(20) '$.sku',
           qty int          '$.qty') AS l;

-- Update in place
SET @doc = JSON_MODIFY(@doc, '$.order.status', 'delivered');
SET @doc = JSON_MODIFY(@doc, 'append $.tags', 'reviewed');

NULL handling differs by function

JSON_OBJECT and JSON_OBJECTAGG default to NULL ON NULL (the key is kept with a JSON null); JSON_ARRAY and JSON_ARRAYAGG default to ABSENT ON NULL (the element is dropped). Paths are lax by default — a missing path returns NULL — and strict turns that into an error.

Regular expressions

Seven functions bring RE2-style regular expressions to T-SQL. Flags are i (case-insensitive), c (case-sensitive, the default), m (multi-line) and s (dot matches newline).

FunctionReturnsTypical use
REGEXP_LIKE(str, pattern [, flags])Boolean (a predicate)WHERE and CHECK validation; needs compatibility level 170
REGEXP_REPLACE(str, pattern, repl [, start, occurrence, flags])String; occurrence 0 = allMasking, normalizing
REGEXP_SUBSTR(str, pattern [, start, occurrence, flags, group])The nth match (or group), else NULLExtracting codes
REGEXP_INSTR(…)Start or end positionLocating a match
REGEXP_COUNT(str, pattern [, start, flags])Number of matchesCounting tokens
REGEXP_MATCHES(str, pattern [, flags])Table: match_id, positions, match_value, substring_matches (json)All matches with capture groups
REGEXP_SPLIT_TO_TABLE(str, pattern [, flags])Table of piecesSplitting on a pattern, not one character
T-SQL
-- Applies to: SQL Server 2025, Azure SQL Database, Azure SQL MI (2025/AUTD), SQL database in Fabric
-- REGEXP_LIKE and the table-valued functions need compatibility level 170.

-- Validate: a CHECK constraint with REGEXP_LIKE
ALTER TABLE dbo.Customer ADD CONSTRAINT CK_Customer_Email
    CHECK (REGEXP_LIKE(Email, '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'));

SELECT Name
FROM dbo.Customer
WHERE REGEXP_LIKE(Phone, '^\+34 ?[6-9]\d{8}$');           -- anchored with ^ → SARGable

SELECT REGEXP_REPLACE(N'Call 555-123-4567 now', '\d{3}-\d{3}-(\d{4})', 'XXX-XXX-\1') AS masked,  -- \1 backreference in replacement
       REGEXP_SUBSTR(N'ORD-2026-00042', '\d+', 1, 2)         AS second_number,  -- 00042
       REGEXP_INSTR(N'abc123', '\d')                         AS first_digit_at, -- 4
       REGEXP_COUNT(N'a,b,,c', ',')                           AS commas;         -- 3

-- Table-valued: every match, and split by a pattern
SELECT match_id, match_value FROM REGEXP_MATCHES(N'#sql #ai #vectors', '#(\w+)');
SELECT value FROM REGEXP_SPLIT_TO_TABLE(N'red; green ,blue', '\s*[;,]\s*');
Applies to SQL Server 2025 Azure SQL Database Azure SQL MI SQL database in Fabric Not available in SQL Server 2022 or earlier. MI: SQL Server 2025 or Always-up-to-date policy. The table-valued functions need compatibility level 170 unless ALLOW_BUILTIN_TVF_IN_ALL_COMPAT_LEVELS is on.

RE2, not .NET or PCRE in full

  • No lookahead, lookbehind or backreferences inside the pattern (RE2 guarantees linear time).
  • Matching ignores the column's collation — REGEXP_LIKE(x, 'abc') is case-sensitive even on a _CI_ column unless you pass 'i'.
  • REGEXP_LIKE can seek an index only when the pattern starts with ^.
  • LOB input is limited to 2 MB; not usable in natively compiled modules.

Fuzzy string matching

Fuzzy functions score how alike two strings are — for typos, transpositions and near-duplicate names that LIKE and regex cannot catch. Edit distance (restricted Damerau-Levenshtein) counts insertions, deletions, substitutions and adjacent transpositions; Jaro-Winkler rewards a shared prefix and suits person and company names.

T-SQL
-- Preview on all four platforms; SQL Server 2025 needs PREVIEW_FEATURES = ON.
-- Supported with Windows and binary collations, not non-binary SQL_* collations.
SELECT EDIT_DISTANCE(N'Jonathan', N'Johnathan')               AS edits,          -- 1
       EDIT_DISTANCE_SIMILARITY(N'Jonathan', N'Johnathan')    AS edit_sim,       -- 0–100
       JARO_WINKLER_DISTANCE(N'Martha', N'Marhta')            AS jw_distance,    -- float, 0 = identical
       JARO_WINKLER_SIMILARITY(N'Martha', N'Marhta')          AS jw_sim;         -- 0–100, rewards a shared prefix

-- Find probable duplicate customers
SELECT a.CustomerID, b.CustomerID, a.Name, b.Name
FROM dbo.Customer AS a
JOIN dbo.Customer AS b ON a.CustomerID < b.CustomerID
WHERE a.PostalCode = b.PostalCode                              -- block first: fuzzy compares are per pair
  AND JARO_WINKLER_SIMILARITY(a.Name, b.Name) >= 90;
Applies to SQL Server 2025 · preview Azure SQL Database · preview Azure SQL MI · preview SQL database in Fabric · preview
Distance or similarity?
Distance functions suit an absolute threshold ("at most 2 edits"); similarity functions return 0–100, easy to compare across strings of different lengths.
Not semantic
"Car" and "automobile" are far apart by edit distance — that is a job for vector search.

Graph queries with MATCH

MATCH describes a pattern of nodes and edges in ASCII-art form inside the WHERE clause: node-(edge)->node. Node and edge tables are listed in FROM (comma-separated); SHORTEST_PATH with FOR PATH aliases finds paths of arbitrary length, aggregated with WITHIN GROUP (GRAPH PATH).

T-SQL
-- Applies to: SQL Server 2017+, Azure SQL Database, Azure SQL MI
-- Friends of Alice, and friends of her friends (two hops)
SELECT p2.Name AS friend, p3.Name AS friend_of_friend
FROM dbo.Person AS p1, dbo.FriendOf AS f1, dbo.Person AS p2,
     dbo.FriendOf AS f2, dbo.Person AS p3
WHERE MATCH(p1-(f1)->p2-(f2)->p3)                              -- arrow = edge direction
  AND p1.Name = N'Alice';

-- Shortest path from Alice to everyone reachable, with the path as text
SELECT p1.Name AS start_person,
       STRING_AGG(p2.Name, N' -> ') WITHIN GROUP (GRAPH PATH) AS path,
       LAST_VALUE(p2.Name)          WITHIN GROUP (GRAPH PATH) AS reached,
       COUNT(p2.PersonID)           WITHIN GROUP (GRAPH PATH) AS hops
FROM dbo.Person AS p1,
     dbo.FriendOf FOR PATH AS fo,
     dbo.Person   FOR PATH AS p2
WHERE MATCH(SHORTEST_PATH(p1(-(fo)->p2)+))                     -- + = one or more hops; {1,3} = up to three
  AND p1.Name = N'Alice';
  • The arrow sets direction: p1-(f)->p2 follows edges from p1 to p2; p1<-(f)-p2 reverses it.
  • MATCH combines with ordinary predicates using AND; it can't sit under OR or NOT.
  • Inside SHORTEST_PATH, tables marked FOR PATH can only be projected through graph-path aggregates (STRING_AGG, LAST_VALUE, COUNT, SUM…).

Correlated queries

A correlated subquery refers to the outer row, so it is logically evaluated once per outer row (the optimizer usually turns it into a join). EXISTS and APPLY are the two forms the exam expects you to reach for.

T-SQL
-- EXISTS: a semi-join, stops at the first match
SELECT c.CustomerID, c.Name
FROM dbo.Customer AS c
WHERE EXISTS (SELECT 1 FROM dbo.OrderHeader AS o
              WHERE o.CustomerID = c.CustomerID AND o.OrderDate >= '2026-10-01');

-- NOT IN with a NULL in the subquery returns no rows at all — use NOT EXISTS
SELECT p.ProductID
FROM dbo.Product AS p
WHERE NOT EXISTS (SELECT 1 FROM dbo.OrderLine AS l WHERE l.ProductID = p.ProductID);

-- Correlated scalar subquery: one value per outer row
SELECT c.CustomerID,
       (SELECT MAX(o.OrderDate) FROM dbo.OrderHeader AS o WHERE o.CustomerID = c.CustomerID) AS last_order
FROM dbo.Customer AS c;

-- CROSS APPLY: a correlated table expression — the last two orders per customer
SELECT c.Name, x.OrderNo, x.Total
FROM dbo.Customer AS c
CROSS APPLY (SELECT TOP (2) o.OrderNo, o.Total
             FROM dbo.OrderHeader AS o
             WHERE o.CustomerID = c.CustomerID
             ORDER BY o.OrderDate DESC) AS x;      -- OUTER APPLY keeps customers with no orders

NOT IN and NULL

WHERE x NOT IN (SELECT y …) returns no rows if the subquery yields a single NULL, because x <> NULL is unknown. NOT EXISTS has no such trap — it is the correct answer whenever the column is nullable.

Error handling

T-SQL
CREATE OR ALTER PROCEDURE dbo.usp_TransferStock
    @FromWarehouse int, @ToWarehouse int, @ProductID int, @Qty int
AS
BEGIN
    SET NOCOUNT ON;
    SET XACT_ABORT ON;                         -- runtime errors doom the transaction
    BEGIN TRY
        IF @Qty <= 0
            THROW 50010, N'Quantity must be positive.', 1;   -- user error: number >= 50000, severity 16

        BEGIN TRANSACTION;
            UPDATE dbo.Stock SET Qty -= @Qty WHERE WarehouseID = @FromWarehouse AND ProductID = @ProductID;
            IF @@ROWCOUNT = 0 THROW 50011, N'No stock row to move from.', 1;
            UPDATE dbo.Stock SET Qty += @Qty WHERE WarehouseID = @ToWarehouse AND ProductID = @ProductID;
        COMMIT TRANSACTION;
    END TRY
    BEGIN CATCH
        IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;   -- -1 uncommittable, 1 committable, 0 none
        INSERT dbo.ErrorLog (Number, Severity, State, ProcName, Line, Message)
        VALUES (ERROR_NUMBER(), ERROR_SEVERITY(), ERROR_STATE(),
                ERROR_PROCEDURE(), ERROR_LINE(), ERROR_MESSAGE());
        THROW;                                       -- re-raise the original error unchanged
    END CATCH;
END;
ConstructBehaviour
TRY…CATCHCatches errors with severity 11–19. Compile errors in the same batch, and severity 20+ (connection-terminating), are not caught.
THROW (no arguments)Inside CATCH: re-raises the original error number, message and state — the preferred way to propagate.
THROW number, message, stateRaises a user error: number 50000 or higher, severity always 16; the previous statement must end with ;.
RAISERRORLegacy: chooses severity, supports printf formatting, does not by itself stop the batch below severity 11.
XACT_STATE()1 open and committable, -1 open but doomed (must roll back), 0 no transaction.
SET XACT_ABORT ONAny runtime error rolls back the whole transaction — recommended in procedures that open transactions.
ERROR_NUMBER(), ERROR_MESSAGE(), ERROR_SEVERITY(), ERROR_STATE(), ERROR_LINE(), ERROR_PROCEDURE()Only meaningful inside CATCH; NULL elsewhere.

MERGE

MERGE synchronizes a target with a source in one statement — the usual tool for reference data in a post-deployment script and for upserts from staging. OUTPUT $action reports what happened to each row.

T-SQL
-- MERGE: one statement for insert, update and delete against a source
MERGE dbo.Product WITH (HOLDLOCK) AS t          -- HOLDLOCK avoids a race between the match and the insert
USING staging.Product AS s
    ON t.Sku = s.Sku
WHEN MATCHED AND (t.Name <> s.Name OR t.ListPrice <> s.ListPrice) THEN
    UPDATE SET t.Name = s.Name, t.ListPrice = s.ListPrice
WHEN NOT MATCHED BY TARGET THEN
    INSERT (Sku, Name, ListPrice, CategoryID) VALUES (s.Sku, s.Name, s.ListPrice, s.CategoryID)
WHEN NOT MATCHED BY SOURCE THEN
    DELETE
OUTPUT $action, inserted.Sku, deleted.ListPrice AS old_price;   -- the statement must end with ;

MERGE hazards

  • A source with duplicate keys raises an error when two source rows match the same target row.
  • Without HOLDLOCK (serializable range locks), two concurrent upserts can both decide "not matched" and insert duplicates.
  • WHEN NOT MATCHED BY SOURCE THEN DELETE on a partial source deletes everything not in it — filter the target with a CTE first.