JSON & XML Support
SQL Server has supported XML as a first-class data type since 2005 and gained native JSON functions in SQL Server 2016. There is no separate json data type — JSON is stored in NVARCHAR(MAX) and the engine validates and parses it via dedicated functions. For new development, prefer JSON unless you have specific XML schema/XPath requirements.
Producing JSON — FOR JSON
The FOR JSON clause turns any SELECT into a JSON document.
-- AUTO: structure inferred from joins/aliases
SELECT e.employee_id,
e.first_name,
e.last_name,
d.department_name
FROM hr.employees e
JOIN hr.departments d ON d.department_id = e.department_id
WHERE e.department_id = 60
FOR JSON AUTO;
-- PATH: full control via dotted aliases for nested objects
SELECT e.employee_id AS [id],
e.first_name AS [name.first],
e.last_name AS [name.last],
d.department_name AS [department.name],
l.city AS [department.location.city]
FROM hr.employees e
JOIN hr.departments d ON d.department_id = e.department_id
JOIN hr.locations l ON l.location_id = d.location_id
WHERE e.employee_id = 100
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER, INCLUDE_NULL_VALUES;
| Option | Effect |
|---|---|
WITHOUT_ARRAY_WRAPPER |
Emits a single object instead of [ {...} ] — useful for one-row results |
INCLUDE_NULL_VALUES |
Keeps "col": null instead of omitting nulls |
ROOT('label') |
Wraps everything in {"label": [...]} |
Consuming JSON — OPENJSON, JSON_VALUE, JSON_QUERY
DECLARE @doc NVARCHAR(MAX) = N'
{
"order_id": 105,
"customer": { "id": 1, "name": "Acme Corp" },
"lines": [
{ "sku": "A-100", "qty": 2, "price": 15.50 },
{ "sku": "B-220", "qty": 1, "price": 42.00 }
]
}';
-- Scalar values
SELECT JSON_VALUE(@doc, '$.order_id') AS order_id,
JSON_VALUE(@doc, '$.customer.name') AS customer_name,
JSON_QUERY(@doc, '$.customer') AS customer_obj;
-- Shred an array into rows
SELECT sku, qty, price
FROM OPENJSON(@doc, '$.lines')
WITH (
sku NVARCHAR(20) '$.sku',
qty INT '$.qty',
price DECIMAL(10,2) '$.price'
);
JSON_VALUEreturns a scalar (max 4000 chars) — wrap inCASTfor typing.JSON_QUERYreturns an object or array as JSON text.OPENJSONis a table-valued function; theWITHclause is optional but lets you project typed columns.
Validating & Modifying JSON
-- Reject invalid documents at insert time
ALTER TABLE hr.employees
ADD CONSTRAINT chk_meta_json CHECK (ISJSON(profile_meta) = 1);
-- Update a single property in place
UPDATE hr.employees
SET profile_meta = JSON_MODIFY(profile_meta, '$.timezone', 'Asia/Kolkata')
WHERE employee_id = 100;
-- Append to an array (lax mode)
UPDATE hr.employees
SET profile_meta = JSON_MODIFY(profile_meta, 'append $.tags', 'on-call')
WHERE employee_id = 100;
JSON_PATH_EXISTS(@doc, '$.lines[0].sku') (SQL 2022+) returns 1/0 for path existence checks without parsing the value.
XML — xml Data Type
DECLARE @x XML = N'
<order id="105">
<customer id="1">Acme Corp</customer>
<lines>
<line sku="A-100" qty="2" price="15.50"/>
<line sku="B-220" qty="1" price="42.00"/>
</lines>
</order>';
-- .value(): scalar via XPath, second arg is SQL type
SELECT @x.value('(/order/@id)[1]', 'INT') AS order_id,
@x.value('(/order/customer)[1]', 'NVARCHAR(100)') AS customer;
-- .nodes(): shred to rowset
SELECT ln.value('@sku', 'NVARCHAR(20)') AS sku,
ln.value('@qty', 'INT') AS qty,
ln.value('@price', 'DECIMAL(10,2)') AS price
FROM @x.nodes('/order/lines/line') AS L(ln);
-- .exist(): predicate, returns 1/0
SELECT @x.exist('/order/lines/line[@qty > 1]') AS has_multi;
-- .query(): returns an XML fragment
SELECT @x.query('/order/lines/line[@qty > 1]') AS multi_lines;
Producing XML — FOR XML
SELECT e.employee_id, e.first_name, e.last_name
FROM hr.employees e
WHERE e.department_id = 60
FOR XML PATH('employee'), ROOT('employees');
FOR XML AUTO infers structure from the query; PATH lets you shape it via column aliases (e.g. [@id], [name/first], [*]); RAW produces a flat element per row.
Indexing XML
-- Primary XML index (required first) — cracks the document into a B-tree
CREATE PRIMARY XML INDEX pxi_emp_meta
ON hr.employees(profile_xml);
-- Secondary indexes for path / value / property queries
CREATE XML INDEX sxi_emp_meta_path
ON hr.employees(profile_xml)
USING XML INDEX pxi_emp_meta FOR PATH;
JSON has no equivalent dedicated index, but you can index a computed column that extracts a JSON property:
ALTER TABLE hr.employees
ADD timezone AS JSON_VALUE(profile_meta, '$.timezone') PERSISTED;
CREATE NONCLUSTERED INDEX ix_emp_timezone ON hr.employees(timezone);
Best Practices & Pitfalls
- Prefer JSON for new work. It's terser, ubiquitous in APIs, and the functions are simpler. XML is heavier but unmatched for schema-validated documents (
xml(SCHEMACOLLECTION)). - Don't query JSON for joins repeatedly without indexed computed columns — every
JSON_VALUEis a string parse, and that's a recipe for scans. JSON_VALUEtruncates at 4000 chars. For long values useJSON_QUERYor shred viaOPENJSON.- Use
ISJSON/CHECK constraints to keep junk out ofNVARCHAR(MAX)columns acting as JSON. - Avoid storing JSON when relational works. If you query individual properties constantly, normalise into proper columns/tables.
- XML namespaces require
WITH XMLNAMESPACES(...)at the top of XQuery statements — easy to forget. - For SQL 2022+, prefer
JSON_PATH_EXISTSoverJSON_VALUE(...) IS NOT NULLfor existence checks.