Why Convert XML to JSON with XSLT 3.0?
Generic online converters make three decisions on your behalf, silently, and all three are frequently wrong:
- Attributes vs elements. Does
<order id="1"/>become{"id": 1},{"@id": "1"}, or{"$": {"id": "1"}}? Every converter picks a different prefix convention. - The single-element array problem. A list with one
<order>looks identical to a single object. Generic converters emit an object when there is one child and an array when there are two, so the JSON shape changes depending on the data — which breaks strictly typed consumers. - Types. XML text is untyped.
total="120.50"could be a number or a string, andactive="true"could be a boolean or a string. A converter has to guess; you do not.
With XSLT 3.0 you declare each of those choices. An array stays an array whether it holds one item or a thousand, a numeric field is a number because you said so, and keys are named to match the contract you are publishing rather than the element names you happened to receive. You also get everything else XSLT offers along the way — grouping, sorting, filtering, aggregation and lookups into secondary documents — in the same pass as the conversion.
The Two Ways to Produce JSON in XSLT 3.0
Maps and arrays + method="json"
Build a native XPath 3.1 map{} or array{} and let the serialiser write it out with xsl:output method="json". The most concise route for straightforward structures.
Node vocabulary + xml-to-json()
Build elements in the XPath functions namespace and pass them to xml-to-json(). Because you are producing nodes, you can use templates, xsl:for-each-group and xsl:sort to assemble the structure.
The JSON-XML Vocabulary
Both xml-to-json() and json-to-xml() speak a small XML vocabulary in the
namespace http://www.w3.org/2005/xpath-functions. Learning these six element names is
most of what the conversion requires:
| Element | JSON produced |
|---|---|
<map> | An object { … }. Each child carries a key attribute. |
<array> | An array [ … ]. Children have no key unless the array is itself a map member. |
<string> | A quoted string; escaping is handled for you. |
<number> | An unquoted JSON number. |
<boolean> | true or false. |
<null/> | null. |
A member of a map needs key="name"; an item of an array does not. Getting a
key wrong is the single most common cause of an FOJS0006 error from
xml-to-json().
Example 1: Maps and Arrays with method="json"
The shortest path from XML to JSON. Build a map, let the serialiser do the rest. Every example on this page runs on Saxon HE 12.5 — paste it into the editor to reproduce the output exactly.
XML input:
<orders>
<order id="1" country="ES" total="120.50"/>
<order id="2" country="FR" total="80.00"/>
<order id="3" country="ES" total="45.25"/>
<order id="4" country="DE" total="200.00"/>
</orders>
Stylesheet:
<xsl:stylesheet version="3.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output method="json" indent="yes"/>
<xsl:template match="/">
<xsl:sequence select="map{
'orderCount': count(orders/order),
'countries': array{ distinct-values(orders/order/@country) },
'grandTotal': sum(orders/order/@total)
}"/>
</xsl:template>
</xsl:stylesheet>
Output:
{
"orderCount": 4,
"countries": [ "ES", "FR", "DE" ],
"grandTotal": 445.75
}
Note array{ … } with curly braces: it builds one array from the whole sequence returned
by the expression. The square-bracket form array[ … ] would instead create one member
per argument.
Example 2: The Node Vocabulary with xml-to-json()
This is the stylesheet behind the
XML → JSON template. It converts each
order element into a JSON object inside an array, choosing per field whether the value
is a number or a string. The default namespace on <array> puts the whole
structure into the XPath functions namespace, so every descendant inherits it.
Stylesheet:
<xsl:stylesheet version="3.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output method="text"/>
<xsl:template match="/">
<xsl:variable name="data">
<array xmlns="http://www.w3.org/2005/xpath-functions">
<xsl:for-each select="orders/order">
<map>
<number key="id"><xsl:value-of select="@id"/></number>
<string key="country"><xsl:value-of select="@country"/></string>
<number key="total"><xsl:value-of select="@total"/></number>
</map>
</xsl:for-each>
</array>
</xsl:variable>
<xsl:value-of select="xml-to-json($data, map{'indent': true()})"/>
</xsl:template>
</xsl:stylesheet>
Output:
[
{ "id" : 1,
"country" : "ES",
"total" : 120.5 },
{ "id" : 2,
"country" : "FR",
"total" : 80 },
{ "id" : 3,
"country" : "ES",
"total" : 45.25 },
{ "id" : 4,
"country" : "DE",
"total" : 200 } ]
Two details worth noticing. First, the indent option is advisory — the JSON is correct
but the exact whitespace is processor-defined, so never diff serialised JSON against a golden file
character by character. Second, 120.50 came out as 120.5 and
200.00 as 200, because JSON numbers have no trailing zeros.
<string key="total"> instead of <number>, or format it first
with format-number(@total, '0.00'). This is exactly the kind of decision a generic
converter takes away from you.
Example 3: Reshaping — Grouping XML into Nested JSON
Here is what full control actually buys you. Instead of mirroring the input structure, this stylesheet groups the orders by country, aggregates a total per group, sorts the groups, and emits a nested document with a metadata header. No generic converter can produce this, because it is not a mapping of the input — it is a transformation of it.
Stylesheet:
<xsl:stylesheet version="3.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:fn="http://www.w3.org/2005/xpath-functions"
exclude-result-prefixes="fn">
<xsl:output method="text"/>
<xsl:template match="/">
<xsl:variable name="json-xml">
<fn:map>
<fn:string key="generated">2026-07-22</fn:string>
<fn:number key="orderCount"><xsl:value-of select="count(orders/order)"/></fn:number>
<fn:array key="countries">
<xsl:for-each-group select="orders/order" group-by="@country">
<xsl:sort select="current-grouping-key()"/>
<fn:map>
<fn:string key="code"><xsl:value-of select="current-grouping-key()"/></fn:string>
<fn:number key="total"><xsl:value-of select="sum(current-group()/@total)"/></fn:number>
<fn:array key="orderIds">
<xsl:for-each select="current-group()">
<fn:number><xsl:value-of select="@id"/></fn:number>
</xsl:for-each>
</fn:array>
</fn:map>
</xsl:for-each-group>
</fn:array>
</fn:map>
</xsl:variable>
<xsl:value-of select="xml-to-json($json-xml, map{'indent': true()})"/>
</xsl:template>
</xsl:stylesheet>
Output:
{ "generated" : "2026-07-22",
"orderCount" : 4,
"countries" :
[
{ "code" : "DE",
"total" : 200,
"orderIds" :
[ 4 ] },
{ "code" : "ES",
"total" : 165.75,
"orderIds" :
[ 1,
3 ] },
{ "code" : "FR",
"total" : 80,
"orderIds" :
[ 2 ] } ] }
Note that orderIds stays a JSON array even for DE, which has a single
order. That consistency is the whole point — a consumer can always index it without checking the type first.
Example 4: The Other Direction — json-to-xml()
The same stylesheet can go the other way. json-to-xml() parses a JSON string into the
same vocabulary, which you then navigate with ordinary XPath using the fn: prefix.
This example needs no source document at all — it runs from the XSLT 3.0 named entry point.
Stylesheet:
<xsl:stylesheet version="3.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:fn="http://www.w3.org/2005/xpath-functions"
exclude-result-prefixes="xs fn">
<xsl:output method="xml" indent="yes"/>
<xsl:variable name="json" as="xs:string">{"users":[{"name":"Ada","active":true},{"name":"Linus","active":false}]}</xsl:variable>
<xsl:template name="xsl:initial-template">
<users>
<xsl:for-each select="json-to-xml($json)/fn:map/fn:array[@key='users']/fn:map">
<user name="{fn:string[@key='name']}" active="{fn:boolean[@key='active']}"/>
</xsl:for-each>
</users>
</xsl:template>
</xsl:stylesheet>
Output:
<users>
<user name="Ada" active="true"/>
<user name="Linus" active="false"/>
</users>
If you would rather work with native maps than nodes, parse-json() returns an XPath 3.1
map you can navigate with the lookup operator — parse-json($json)?users?*?name yields
Ada Linus. Use json-doc() instead when the JSON lives at a URI rather than
in a string.
Frequently Asked Questions
- How do I convert XML to JSON with XSLT 3.0?
- Two ways: build an XPath 3.1
map{}orarray{}and serialise it withxsl:output method="json", or build nodes in the XPath functions namespace and pass them toxml-to-json(). Both are shown above and both run on Saxon HE 12.5. - Why use XSLT instead of a generic XML to JSON converter?
- Because there is no canonical XML-to-JSON mapping, a generic converter has to guess how attributes are named, whether a single repeated element is an array, and which strings are really numbers. XSLT 3.0 lets you state each decision explicitly, so the JSON matches the contract your consumer expects.
- What is the difference between
xml-to-json()andjson-to-xml()? xml-to-json()turns nodes in the XPath functions namespace into a JSON string.json-to-xml()parses a JSON string into that same vocabulary so you can process it with ordinary XPath and templates.- Why did
120.50become120.5? - JSON numbers have no trailing zeros, so a
<number>is serialised in canonical form. Emit the value as a<string>, or pre-format it withformat-number(), if the exact digits matter. - Can I convert JSON back to XML online?
- Yes. Use
json-to-xml()for a node tree, orparse-json()for a native map you can navigate with the?lookup operator. Select XSLT 3.0 and both are available. - Is the online XML to JSON converter free?
- Yes — free, no installation, no account. XSLT Playground is the only free online tool with full XSLT 3.0 support, which is what makes this level of control possible in a browser.
Function Reference
Every XSLT and XPath function, documented with syntax and a runnable example: browse the complete XSLT & XPath function reference. The entries that matter most for JSON work:
- xml-to-json() — serialise the node vocabulary to a JSON string
- json-to-xml() — parse JSON into an XML tree
- parse-json() — parse JSON into a native map or array
- serialize() — serialise a result to a string with explicit output parameters
- map:merge() — build and combine maps
- xsl:for-each-group — reshape flat XML into nested JSON
- distinct-values() — deduplicate before building an array
- sum() — aggregate values into a JSON number
Learn More
- Transforming XML to JSON and CSV with XSLT
- XSLT 3.0 new features: what changed from 2.0
- XSLT grouping with xsl:for-each-group — complete guide
- Generating multiple output files with xsl:result-document
Also Available
Still working out which nodes to select before you convert them? Try the XPath Tester →
Everything here needs XSLT 3.0 — see the XSLT 3.0 Online Tester. For grouping and regular expressions without the JSON layer, see the XSLT 2.0 Online Tester.