Day 27: JSON & XML in T-SQL
On this page
By the end of this lesson you can
- Validate JSON and extract scalar values, objects and arrays with path expressions.
- Shred JSON arrays into rows with
OPENJSON … WITHandCROSS APPLY. - Produce nested JSON from relational data with
FOR JSON PATH, and modify documents withJSON_MODIFY. - Produce XML with
FOR XML PATHand query it with.value(),.query(),.exist()and.nodes(). - Choose how to store documents, and make a JSON property searchable with a computed column and index.
A. Lesson overview
| Week | 4 — Performance and expert topics |
| Level | Advanced |
| Estimated time | 2 h 45 min essential + 30 min optional stretch |
| Prerequisites | Day 10 (APPLY), Day 13 (string functions), Day 26 (computed columns) |
| Syllabus topic | JSON & XML — FOR JSON, OPENJSON, JSON_VALUE, FOR XML, XQuery methods |
Activity breakdown
| Activity | Time |
|---|---|
| Part 1 — Primer: JSON and XML documents | 15 min |
Part 2 — Reading JSON: ISJSON, JSON_VALUE, JSON_QUERY, OPENJSON |
25 min |
Part 3 — Writing JSON: FOR JSON, JSON_MODIFY, JSON_OBJECT |
15 min |
Part 4 — XML: FOR XML and the xml methods |
20 min |
| Part 5 — Storing and indexing documents | 10 min |
| Worked examples | 30 min |
| Guided lab (order-event processor) | 30 min |
| Independent practice | 15 min |
| Knowledge check and wrap-up | 5 min |
| Stretch challenge (optional) | 30 min |
Business scenario. The new web shop sends order events as JSON; two suppliers send price catalogues as XML. The data team must load both into relational tables, check them against the product catalogue, and return order summaries as JSON to the web team.
B. Theory explained clearly
Part 1 — Primer: two document formats
JSON (JavaScript Object Notation):
{
"orderRef": "WEB-5001", ← string
"customer": { "id": 1, "email": "…" }, ← object (name/value pairs in { })
"lines": [ { "sku": "TNT-002", "qty": 1 } ], ← array (ordered values in [ ])
"tags": [ "gift" ],
"paid": true, ← true/false
"note": null ← null
}
A path addresses part of a document: $ is the whole document, $.customer.email a nested property, $.lines[0] the first array element (zero-based), $.lines[0].sku a property inside it.
XML (eXtensible Markup Language) uses elements and attributes:
<catalogue supplier="Northern Outdoor Supply" currency="GBP"> ← element with attributes
<item sku="TNT-001"><cost>92.50</cost></item> ← nested elements
</catalogue>
XML is addressed with XPath/XQuery: /catalogue/item (elements), /catalogue/@currency (an attribute), (/catalogue/item/cost)[1] (the first match).
In SQL Server, JSON is stored as text (nvarchar) — or, in SQL Server 2025, in the native json type; XML has its own xml data type.
Part 2 — Reading JSON
| Function | Returns | Example |
|---|---|---|
ISJSON(text) |
1 if valid JSON | CHECK (ISJSON(Payload) = 1) |
JSON_VALUE(text, path) |
A scalar as nvarchar(4000); NULL for objects/arrays |
JSON_VALUE(Payload, '$.customer.email') |
JSON_QUERY(text, path) |
An object or array as JSON text | JSON_QUERY(Payload, '$.lines') |
JSON_PATH_EXISTS(text, path) (2022+) |
1 if the path exists | Required-property checks |
OPENJSON(text [, path]) |
A rowset: one row per property or array element | Shredding arrays |
OPENJSON without a schema returns key, value, type (0 null, 1 string, 2 number, 3 true/false, 4 array, 5 object). With a WITH clause, you define typed columns and their paths:
SELECT e.EventID, l.Sku, l.Qty, l.Price
FROM Lab.OrderEvent AS e
CROSS APPLY OPENJSON(e.Payload, '$.lines')
WITH (Sku varchar(20) '$.sku',
Qty int '$.qty',
Price decimal(10,2) '$.price') AS l;
Paths are lax by default: a missing property returns NULL. Prefix with strict ('strict $.orderRef') to raise an error instead. JSON_VALUE returns text — CAST it to the right type.
Part 3 — Writing JSON
SELECT CustomerID AS customerId, City AS [address.city] -- dots create nested objects
FROM Sales.Customer WHERE CustomerID = 1
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER; -- {"customerId":1,"address":{"city":"London"}}
| Option | Effect |
|---|---|
FOR JSON PATH |
You control names and nesting with column aliases |
FOR JSON AUTO |
Nesting follows the tables in the FROM clause |
ROOT('name') |
Wraps the output in {"name": [...]} |
INCLUDE_NULL_VALUES |
Keeps NULL properties (omitted by default) |
WITHOUT_ARRAY_WRAPPER |
Returns a single object instead of [ … ] |
A correlated subquery with its own FOR JSON PATH produces a nested array. JSON_MODIFY(text, path, value) updates, adds ('append $.tags' adds to an array) or removes (value NULL) a property. JSON_OBJECT and JSON_ARRAY (2022+) build documents from values; SQL Server 2025 adds the aggregates JSON_OBJECTAGG and JSON_ARRAYAGG.
Part 4 — XML
Producing XML:
SELECT ProductCode AS [@code], ProductName AS [name], ListPrice AS [price]
FROM Inventory.Product WHERE CategoryID = 2
FOR XML PATH('product'), ROOT('products'), TYPE;
[@code] becomes an attribute; plain names become elements; TYPE returns the xml type rather than text.
Querying the xml type — five methods, called on a column or variable:
| Method | Returns | Example |
|---|---|---|
.value(xpath, sqltype) |
One scalar (the path must return a single item — hence [1]) |
x.value('(/catalogue/@currency)[1]', 'char(3)') |
.query(xquery) |
An XML fragment | x.query('/catalogue/item[1]') |
.exist(xquery) |
1 if the path matches | x.exist('/catalogue/item[@sku="TNT-001"]') |
.nodes(xquery) |
A rowset with one row per matching node — use with CROSS APPLY |
CROSS APPLY x.nodes('/catalogue/item') AS i(n) |
.modify(xml_dml) |
Changes the XML in an UPDATE |
SET x.modify('delete /catalogue/item[@sku="SLP-002"]') |
For element text, (cost/text())[1] is slightly more efficient than (cost)[1].
Part 5 — Storing and indexing documents
- Store JSON in
nvarchar(max)with aCHECK (ISJSON(…) = 1)constraint — or in thejsontype on SQL Server 2025, which validates on input and is stored in an optimised binary format. - Extract properties you search on into computed columns and index them (Day 26) —
OrderRef AS CAST(JSON_VALUE(Payload, '$.orderRef') AS varchar(20)). - XML has XML indexes (primary and secondary) for large documents; they are big and best for heavy XQuery workloads.
- Shred documents into relational tables when you’ll report on their contents repeatedly; keep the raw document for audit and replay.
C. Dataset and starting state
| Dataset | Shared synthetic dataset plus the Day 27 documents |
| Database | RetailLab |
| Read from | Lab.OrderEvent, Lab.SupplierCatalogue, Sales.Customer, Sales.SalesOrder, Sales.SalesOrderLine, Inventory.Product |
| Changed | Lab.OrderEvent (task 1 and 3) |
| Permissions | CREATE TABLE and ALTER on schema Lab |
| Objects created | By the script: Lab.OrderEvent, Lab.SupplierCatalogue; in practice: computed column OrderRef and index IX_OrderEvent_OrderRef |
Setup. Run scripts/lessons/day27_json_xml.sql (download it). It is destructive for its two tables only.
| Table | Contents |
|---|---|
Lab.OrderEvent |
6 events (EventID, ReceivedAt, Payload): 4 × OrderPlaced (WEB-5001 to WEB-5004; WEB-5004 is a guest with "id": null; WEB-5003 has no tags), 1 × PaymentReceived (WEB-5001, 431.99), 1 × OrderCancelled (WEB-5002) |
Lab.SupplierCatalogue |
2 catalogues: Northern Outdoor Supply (GBP, 3 items, SLP-002 marked <discontinued/>) and Peak Gear Wholesale (EUR, 2 items) |
Open a payload to see its shape: SELECT Payload FROM Lab.OrderEvent WHERE EventID = 1;
Validation. The script ends with this check. Expected: 6, 4, 2, 5.
SELECT (SELECT COUNT(*) FROM Lab.OrderEvent) AS Events,
(SELECT COUNT(*) FROM Lab.OrderEvent
WHERE JSON_VALUE(Payload, '$.eventType') = 'OrderPlaced') AS OrdersPlaced,
(SELECT COUNT(*) FROM Lab.SupplierCatalogue) AS Catalogues,
(SELECT SUM(CatalogueXml.value('count(/catalogue/item)', 'int')) FROM Lab.SupplierCatalogue) AS CatalogueItems;
D. Worked examples
Worked example 1 — Relational to nested JSON
SELECT c.CustomerID AS customerId,
CONCAT(c.FirstName, N' ', c.LastName) AS name,
c.City AS city,
(SELECT o.OrderID AS orderId,
o.OrderDate AS orderDate,
o.OrderStatus AS status,
CAST(ROUND(SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)), 2) + o.ShippingCost
AS decimal(12,2)) AS amountDue
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.CustomerID = c.CustomerID
GROUP BY o.OrderID, o.OrderDate, o.OrderStatus, o.ShippingCost
ORDER BY o.OrderID
FOR JSON PATH) AS orders
FROM Sales.Customer AS c
WHERE c.CustomerID = 35
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER;
Expected result: one value (shown here formatted; SSMS returns it on one line):
{"customerId":35,"name":"Ethan Cole","city":"New York",
"orders":[{"orderId":1362,"orderDate":"2025-12-21","status":"Shipped","amountDue":33.95},
{"orderId":1364,"orderDate":"2025-12-22","status":"Shipped","amountDue":476.00}]}
The inner FOR JSON PATH is nested as an array because its result is JSON — SQL Server doesn’t escape it as a string. Dates use ISO format; numbers keep their scale. Click the result in SSMS to open it in a separate window.
Worked example 2 — Extracting values
SELECT EventID,
JSON_VALUE(Payload, '$.eventType') AS EventType,
JSON_VALUE(Payload, '$.orderRef') AS OrderRef,
CAST(JSON_VALUE(Payload, '$.customer.id') AS int) AS CustomerID,
CAST(JSON_VALUE(Payload, '$.shipping.cost') AS decimal(10,2)) AS ShippingCost,
JSON_QUERY(Payload, '$.tags') AS Tags,
JSON_VALUE(Payload, '$.lines') AS LinesAsValue -- NULL: it's an array
FROM Lab.OrderEvent
ORDER BY EventID;
Expected result: 6 rows. EventType/OrderRef: 1 OrderPlaced WEB-5001, 2 OrderPlaced WEB-5002, 3 OrderPlaced WEB-5003, 4 PaymentReceived WEB-5001, 5 OrderCancelled WEB-5002, 6 OrderPlaced WEB-5004. CustomerID: 1, 2, 40, NULL, NULL, NULL. ShippingCost: 4.99, 9.99, 4.99, NULL, NULL, 0.00. Tags: [ "gift" ], [], NULL, NULL, NULL, [ "guest", "click-and-collect" ] (whitespace as stored). LinesAsValue is NULL everywhere — use JSON_QUERY for arrays and objects. Missing paths (lax mode) give NULL, not errors.
Worked example 3 — Shredding an array
OPENJSON with its default schema returns each array element’s position (key, starting at 0) and its JSON (value); a second OPENJSON … WITH turns each element into typed columns:
SELECT JSON_VALUE(e.Payload, '$.orderRef') AS OrderRef,
CAST(a.[key] AS int) + 1 AS LineNumber,
l.Sku, l.Qty, l.Price, p.ListPrice,
CASE WHEN p.ProductID IS NULL THEN 'Unknown SKU'
WHEN l.Price <> p.ListPrice THEN 'Price differs'
ELSE 'OK' END AS CheckResult
FROM Lab.OrderEvent AS e
CROSS APPLY OPENJSON(e.Payload, '$.lines') AS a -- key = 0, 1, 2…; value = element JSON
CROSS APPLY OPENJSON(a.value)
WITH (Sku varchar(20) '$.sku',
Qty int '$.qty',
Price decimal(10,2) '$.price') AS l
LEFT JOIN Inventory.Product AS p ON p.ProductCode = l.Sku
WHERE JSON_VALUE(e.Payload, '$.eventType') = 'OrderPlaced'
ORDER BY OrderRef, LineNumber;
Expected result: 7 rows, all OK:
| OrderRef | LineNumber | Sku | Qty | Price | ListPrice |
|---|---|---|---|---|---|
| WEB-5001 | 1 | TNT-002 | 1 | 349.00 | 349.00 |
| WEB-5001 | 2 | LGT-001 | 2 | 39.00 | 39.00 |
| WEB-5002 | 1 | SLP-001 | 1 | 129.00 | 129.00 |
| WEB-5003 | 1 | COK-001 | 1 | 45.00 | 45.00 |
| WEB-5003 | 2 | COK-003 | 4 | 6.50 | 6.50 |
| WEB-5003 | 3 | NAV-001 | 1 | 24.99 | 24.99 |
| WEB-5004 | 1 | BPK-002 | 1 | 199.00 | 199.00 |
Events without a lines array (payment, cancellation) produce no rows from CROSS APPLY — use OUTER APPLY to keep them. The column is called LineNumber because LINENO is a reserved word in T-SQL.
Worked example 4 — Shredding XML
SELECT s.SupplierID,
s.CatalogueXml.value('(/catalogue/@supplier)[1]', 'nvarchar(60)') AS Supplier,
s.CatalogueXml.value('(/catalogue/@currency)[1]', 'char(3)') AS Currency,
i.n.value('@sku', 'varchar(20)') AS Sku,
i.n.value('(cost/text())[1]', 'decimal(10,2)') AS SupplierCost,
i.n.value('(leadTimeDays/text())[1]', 'int') AS LeadTimeDays,
i.n.exist('discontinued') AS IsDiscontinued,
p.StandardCost
FROM Lab.SupplierCatalogue AS s
CROSS APPLY s.CatalogueXml.nodes('/catalogue/item') AS i(n)
LEFT JOIN Inventory.Product AS p ON p.ProductCode = i.n.value('@sku', 'varchar(20)')
ORDER BY s.SupplierID, Sku;
Expected result:
| SupplierID | Supplier | Currency | Sku | SupplierCost | LeadTimeDays | IsDiscontinued | StandardCost |
|---|---|---|---|---|---|---|---|
| 1 | Northern Outdoor Supply | GBP | SLP-002 | 149.00 | 30 | 1 | 150.00 |
| 1 | Northern Outdoor Supply | GBP | TNT-001 | 92.50 | 14 | 0 | 95.00 |
| 1 | Northern Outdoor Supply | GBP | TNT-002 | 176.00 | 21 | 0 | 180.00 |
| 2 | Peak Gear Wholesale | EUR | LGT-001 | 16.20 | 7 | 0 | 15.00 |
| 2 | Peak Gear Wholesale | EUR | NAV-002 | 205.00 | 10 | 0 | 190.00 |
.nodes() turns each <item> into a row; .value() reads from that node (relative paths) or from the whole document (absolute paths). Note the trap in the last two rows: EUR costs can’t be compared with GBP standard costs without an exchange rate.
E. Guided practical lab — An order-event processor
Turn the JSON events into an order status summary and return it as JSON.
Step 1 — Validate required properties
- Do: Every OrderPlaced event must have an order reference, a customer email and at least one line.
- Run:
USE RetailLab; GO SELECT EventID, JSON_PATH_EXISTS(Payload, '$.orderRef') AS HasOrderRef, JSON_PATH_EXISTS(Payload, '$.customer.email') AS HasEmail, (SELECT COUNT(*) FROM OPENJSON(Payload, '$.lines')) AS LineCount FROM Lab.OrderEvent WHERE JSON_VALUE(Payload, '$.eventType') = 'OrderPlaced' ORDER BY EventID; - Observe: 4 rows (events 1, 2, 3, 6), each
1, 1with line counts 2, 1, 3, 1. - Why it matters:
ISJSONonly proves the text is JSON; business validation checks the content. - Confirm: no zeros.
Step 2 — Order headers into a temporary table
- Do: Shred one row per placed order.
- Run:
DROP TABLE IF EXISTS #OrderHeader; SELECT h.OrderRef, h.CustomerID, h.Email, h.ShippingMethod, h.ShippingCost, e.ReceivedAt INTO #OrderHeader FROM Lab.OrderEvent AS e CROSS APPLY OPENJSON(e.Payload) WITH (EventType varchar(30) '$.eventType', OrderRef varchar(20) '$.orderRef', CustomerID int '$.customer.id', Email varchar(100) '$.customer.email', ShippingMethod varchar(20) '$.shipping.method', ShippingCost decimal(10,2) '$.shipping.cost') AS h WHERE h.EventType = 'OrderPlaced'; SELECT * FROM #OrderHeader ORDER BY OrderRef; - Observe: 4 rows: WEB-5001 (1, Standard, 4.99), WEB-5002 (2, Express, 9.99), WEB-5003 (40, Standard, 4.99), WEB-5004 (NULL, guest.buyer@example.com, Collect, 0.00).
- Why it matters:
OPENJSONon the whole document with nested paths inWITHreads many properties in one pass — tidier than manyJSON_VALUEcalls. - Confirm: the guest’s
CustomerIDis NULL, not an error.
Step 3 — Order totals
- Do: Sum the lines and add shipping.
- Run:
DROP TABLE IF EXISTS #OrderTotal; SELECT h.OrderRef, SUM(l.Qty * l.Price) + h.ShippingCost AS OrderTotal, COUNT(*) AS LineCount INTO #OrderTotal FROM #OrderHeader AS h JOIN Lab.OrderEvent AS e ON JSON_VALUE(e.Payload, '$.orderRef') = h.OrderRef AND JSON_VALUE(e.Payload, '$.eventType') = 'OrderPlaced' CROSS APPLY OPENJSON(e.Payload, '$.lines') WITH (Qty int '$.qty', Price decimal(10,2) '$.price') AS l GROUP BY h.OrderRef, h.ShippingCost; SELECT * FROM #OrderTotal ORDER BY OrderRef; - Observe: WEB-5001 431.99 (2 lines), WEB-5002 138.99 (1), WEB-5003 100.98 (3), WEB-5004 199.00 (1).
- Why it matters: Once shredded, documents are just rows — everything from Weeks 1–3 applies.
- Confirm: 4 rows.
Step 4 — Status from the other events
- Do: Combine payments and cancellations.
- Run:
SELECT t.OrderRef, t.OrderTotal, CASE WHEN c.OrderRef IS NOT NULL THEN 'Cancelled' WHEN p.Paid >= t.OrderTotal THEN 'Paid' WHEN p.Paid > 0 THEN 'Part paid' ELSE 'Awaiting payment' END AS OrderState FROM #OrderTotal AS t LEFT JOIN (SELECT JSON_VALUE(Payload, '$.orderRef') AS OrderRef, SUM(CAST(JSON_VALUE(Payload, '$.payment.amount') AS decimal(10,2))) AS Paid FROM Lab.OrderEvent WHERE JSON_VALUE(Payload, '$.eventType') = 'PaymentReceived' GROUP BY JSON_VALUE(Payload, '$.orderRef')) AS p ON p.OrderRef = t.OrderRef LEFT JOIN (SELECT DISTINCT JSON_VALUE(Payload, '$.orderRef') AS OrderRef FROM Lab.OrderEvent WHERE JSON_VALUE(Payload, '$.eventType') = 'OrderCancelled') AS c ON c.OrderRef = t.OrderRef ORDER BY t.OrderRef; - Observe: WEB-5001 Paid, WEB-5002 Cancelled, WEB-5003 Awaiting payment, WEB-5004 Awaiting payment.
- Why it matters: Event streams describe what happened; state is derived by combining events.
- Confirm: 4 rows.
Step 5 — Return the summary as JSON
- Do: Wrap step 4’s query in
FOR JSON. - Run: add
FOR JSON PATH, ROOT('orders')after theORDER BYof step 4, and alias the columnsorderRef,total,state. - Observe:
{"orders":[{"orderRef":"WEB-5001","total":431.99,"state":"Paid"},{"orderRef":"WEB-5002","total":138.99,"state":"Cancelled"},{"orderRef":"WEB-5003","total":100.98,"state":"Awaiting payment"},{"orderRef":"WEB-5004","total":199.00,"state":"Awaiting payment"}]} - Why it matters: The round trip — JSON in, relational processing, JSON out — is the typical integration pattern.
- Confirm:
ISJSON()of the result is 1.
F. Independent practice
Task 1 — Modify documents
Using JSON_MODIFY: (a) add "status": "Cancelled" to event 2; (b) append "vip" to event 1’s tags; (c) remove shipping.city from event 3.
Success criteria: event 2: JSON_VALUE(Payload, '$.status') = Cancelled; event 1: OPENJSON(Payload, '$.tags') returns 2 rows, gift and vip; event 3: JSON_PATH_EXISTS(Payload, '$.shipping.city') = 0 while $.shipping.cost is still 4.99. Every payload still passes the CHECK constraint.
Task 2 — Relational to XML and back
Produce the products in category 2 as XML — root <products>, one <product code="…"> each with <name> and <price> — into an xml variable. Then, from the variable, return the number of products and the name of the most expensive one.
Success criteria: 4 <product> elements; most expensive Classic Canvas Bell Tent (599.00).
Task 3 — Make a JSON property searchable
Add a computed column OrderRef AS CAST(JSON_VALUE(Payload, '$.orderRef') AS varchar(20)) to Lab.OrderEvent, index it, and list all events for WEB-5001 and for WEB-5002.
Success criteria: WEB-5001 → events 1 and 4; WEB-5002 → events 2 and 5. The query uses IX_OrderEvent_OrderRef (with six rows the optimiser may still prefer a scan — add WITH (INDEX(IX_OrderEvent_OrderRef)) once to see the seek).
Task 4 — Query the XML with conditions
List catalogue items with a lead time over 10 days that are not discontinued, with supplier name and currency — filtering inside XQuery or after shredding.
Success criteria: 2 rows: TNT-001 (14 days) and TNT-002 (21 days), both Northern Outdoor Supply, GBP.
Stretch challenge — SQL Server 2025 JSON features
(a) Create a table variable or table with a column of the native json type and insert event 1’s payload. (b) Using JSON_ARRAYAGG, return one row per order of customer 35 with a JSON array of its product codes in code order. (c) Try inserting N'{"broken": }' into the json column.
Success criteria: (b) 1362 → ["BPK-003"]; 1364 → ["COK-002","FTW-002","SLP-001"]. (c) fails with a JSON parsing error. If your build reports json or JSON_ARRAYAGG as unknown, check the version (SELECT @@VERSION;) — these require SQL Server 2025.
G. Solutions and explanations
Show solution 1
UPDATE Lab.OrderEvent SET Payload = JSON_MODIFY(Payload, '$.status', 'Cancelled') WHERE EventID = 2;
UPDATE Lab.OrderEvent SET Payload = JSON_MODIFY(Payload, 'append $.tags', 'vip') WHERE EventID = 1;
UPDATE Lab.OrderEvent SET Payload = JSON_MODIFY(Payload, '$.shipping.city', NULL) WHERE EventID = 3;
SELECT JSON_VALUE(Payload, '$.status') FROM Lab.OrderEvent WHERE EventID = 2;
SELECT t.value AS Tag FROM Lab.OrderEvent CROSS APPLY OPENJSON(Payload, '$.tags') AS t WHERE EventID = 1;
SELECT JSON_PATH_EXISTS(Payload, '$.shipping.city') AS HasCity,
JSON_VALUE(Payload, '$.shipping.cost') AS Cost
FROM Lab.OrderEvent WHERE EventID = 3;
Reasoning. In lax mode, setting a missing property adds it; setting a property to NULL deletes it (use 'strict $.x' with NULL to store a JSON null instead — or JSON_MODIFY(…, '$.x', JSON_QUERY('null'))). append adds to the end of an array. JSON_MODIFY returns a new document; the UPDATE writes it back, and the CHECK constraint re-validates it. Re-run the Day 27 script to restore the original payloads.
Show solution 2
DECLARE @x xml =
(
SELECT ProductCode AS [@code], ProductName AS [name], ListPrice AS [price]
FROM Inventory.Product
WHERE CategoryID = 2
ORDER BY ProductCode
FOR XML PATH('product'), ROOT('products'), TYPE
);
SELECT @x AS ProductsXml;
SELECT @x.value('count(/products/product)', 'int') AS Products;
SELECT TOP (1) p.n.value('(name/text())[1]', 'nvarchar(100)') AS ProductName,
p.n.value('(price/text())[1]', 'decimal(10,2)') AS Price
FROM @x.nodes('/products/product') AS p(n)
ORDER BY Price DESC;
Reasoning. TYPE makes the subquery return xml, so it can be assigned to an xml variable and queried. XQuery also has max(), but .nodes() plus SQL’s ORDER BY is easier to read and returns the name alongside the price.
Show solution 3
ALTER TABLE Lab.OrderEvent ADD OrderRef AS CAST(JSON_VALUE(Payload, '$.orderRef') AS varchar(20));
CREATE INDEX IX_OrderEvent_OrderRef ON Lab.OrderEvent (OrderRef);
SELECT EventID, OrderRef, JSON_VALUE(Payload, '$.eventType') AS EventType
FROM Lab.OrderEvent
WHERE OrderRef IN ('WEB-5001', 'WEB-5002')
ORDER BY OrderRef, EventID;
Reasoning. JSON_VALUE is deterministic, so the computed column can be indexed without being persisted (Day 26). Queries that use the same expression — WHERE CAST(JSON_VALUE(Payload, '$.orderRef') AS varchar(20)) = … — can also be matched to the index. Re-running the Day 27 script removes the column, because it drops and recreates the table.
Show solution 4
-- Filter inside XQuery
SELECT s.CatalogueXml.value('(/catalogue/@supplier)[1]', 'nvarchar(60)') AS Supplier,
s.CatalogueXml.value('(/catalogue/@currency)[1]', 'char(3)') AS Currency,
i.n.value('@sku', 'varchar(20)') AS Sku,
i.n.value('(leadTimeDays/text())[1]', 'int') AS LeadTimeDays
FROM Lab.SupplierCatalogue AS s
CROSS APPLY s.CatalogueXml.nodes('/catalogue/item[leadTimeDays > 10][not(discontinued)]') AS i(n)
ORDER BY Sku;
Reasoning. XPath predicates in [ ] filter nodes before they become rows; not(discontinued) tests for the absence of a child element. The same result comes from shredding everything and filtering in SQL (WHERE LeadTimeDays > 10 AND i.n.exist('discontinued') = 0) — choose whichever your team reads more easily.
Show the stretch solution
-- (a) Native json type (SQL Server 2025)
DECLARE @Docs TABLE (DocID int PRIMARY KEY, Doc json NOT NULL);
INSERT INTO @Docs (DocID, Doc) SELECT EventID, Payload FROM Lab.OrderEvent WHERE EventID = 1;
SELECT DocID, JSON_VALUE(Doc, '$.orderRef') AS OrderRef FROM @Docs;
-- (b) JSON_ARRAYAGG
SELECT o.OrderID,
JSON_ARRAYAGG(p.ProductCode ORDER BY p.ProductCode) AS ProductCodes
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.CustomerID = 35
GROUP BY o.OrderID
ORDER BY o.OrderID;
-- (c) Invalid JSON is rejected on input
INSERT INTO @Docs (DocID, Doc) VALUES (2, N'{"broken": }');
Reasoning. The json type validates documents on input and stores them in a binary format, so you no longer need an ISJSON check constraint; the JSON functions work on it directly. JSON_ARRAYAGG builds a proper JSON array (with correct quoting and escaping), which is safer than gluing strings with STRING_AGG. On earlier versions, CONCAT('[', STRING_AGG(QUOTENAME(ProductCode, '"'), ','), ']') is a rough equivalent for simple values — but doesn’t escape special characters correctly.
H. Knowledge check
-
Understanding. When do you use
JSON_VALUE, and whenJSON_QUERY? -
Query interpretation.
SELECT JSON_VALUE(Payload, '$.customer.id') FROM Lab.OrderEvent WHERE EventID = 6;returns NULL. Is the property missing? -
Debugging.
.value('/catalogue/item/cost', 'decimal(10,2)')fails with ‘value()’ requires a singleton (or empty sequence). Fix it. -
Understanding. Why is
CROSS APPLY OPENJSON(…, '$.lines')the right way to report on order lines stored in JSON, rather thanJSON_VALUE(Payload, '$.lines[0].sku'),'$.lines[1].sku', …? -
Understanding. Name two ways to make searching on a JSON property efficient.
Show the answers
-
JSON_VALUEfor a single scalar value (string, number, true/false);JSON_QUERYfor an object or array, returned as JSON text. -
Not necessarily — event 6 has
"id": null, a JSON null, whichJSON_VALUEreturns as SQL NULL. A missing property also gives NULL in lax mode; useJSON_PATH_EXISTS(orstrictmode) to tell them apart. HereJSON_PATH_EXISTS(Payload, '$.customer.id')returns 1. -
Make the path return one item:
.value('(/catalogue/item/cost)[1]', 'decimal(10,2)')— or, to get all items, use.nodes('/catalogue/item')and read(cost/text())[1]per node. -
The number of lines varies per document.
OPENJSONreturns one row per array element, however many there are; fixed indexes miss extra lines and produce NULLs for missing ones. -
A computed column on
JSON_VALUE(…)with an index; shredding the documents into relational tables with normal indexes; (for XML, XML indexes).
I. Troubleshooting and cleanup
| Error or symptom | Cause | Fix |
|---|---|---|
| The INSERT statement conflicted with the CHECK constraint “CK_OrderEvent_IsJson” | Invalid JSON (often a missing quote or trailing comma) | Validate with ISJSON; fix the text |
| JSON text is not properly formatted. Unexpected character … | OPENJSON/JSON_VALUE over invalid JSON |
Filter with WHERE ISJSON(col) = 1 |
JSON_VALUE returns NULL unexpectedly |
Path wrong (paths are case-sensitive!), value is an object/array, or value longer than 4000 characters | Check case; use JSON_QUERY or OPENJSON … WITH (… nvarchar(max)) |
| Property cannot be found on the specified JSON path | strict path to a missing property |
Use lax mode or check with JSON_PATH_EXISTS |
| XQuery [value()]: ‘value()’ requires a singleton | Path can match several nodes | Add [1] or use .nodes() |
Element text from .value() is wrong for mixed content |
Reading the element, not its text | Use (element/text())[1] |
FOR JSON nested result appears as an escaped string |
Subquery text wrapped in a function or CAST |
Return it directly, or wrap with JSON_QUERY() |
Cleanup. Optional — re-running the Day 27 script resets both tables. To remove them:
DROP TABLE IF EXISTS Lab.OrderEvent, Lab.SupplierCatalogue;
J. Completion checklist, recap and references
Completion checklist
- Worked example 1 produced the nested JSON for customer 35.
- The 7 shredded order lines all matched list prices.
- The 5 catalogue items shredded, with the EUR/GBP caveat noted.
- Order states: Paid, Cancelled, Awaiting payment, Awaiting payment — returned as JSON.
- Tasks 1–4 match the success criteria.
Recap
- JSON paths:
$.a.b,$.arr[0]; lax by default (missing → NULL),strictto raise errors. JSON_VALUEfor scalars,JSON_QUERYfor objects/arrays,OPENJSON … WITH+CROSS APPLYfor arrays.FOR JSON PATHwith aliases and nested subqueries builds documents;JSON_MODIFYedits them.- XML:
FOR XML PATH … ROOT … TYPEto produce;.value,.query,.exist,.nodes,.modifyto consume. - Validate on input (
ISJSONconstraint or the 2025jsontype); index searched properties with computed columns; shred for repeated reporting.
Next: Day 28 — Dynamic SQL and metaprogramming: sp_executesql, parameterisation, QUOTENAME, identifier validation and injection prevention.