XML still powers a huge portion of the web — RSS/Atom feeds, SOAP APIs, Android manifests, Maven pom.xml, Microsoft Office files, SVG, and countless enterprise integrations. JSON is what modern frontends and microservices speak. Converting between them is a routine task, but XML's data model doesn't map cleanly to JSON's, so naive conversions frequently lose information or produce surprises.
This guide explains the tricky parts and shows working code in JavaScript, Python, Go, and PHP.
Why XML-to-JSON is tricky
XML has features that have no JSON equivalent:
| XML feature | Problem |
|---|---|
Attributes (<tag id="1">) |
JSON has no concept of "attribute vs element" |
| Text nodes mixed with children | <p>Hello <b>world</b></p> — text and elements are siblings |
| Repeated elements | <item>a</item><item>b</item> — should become an array, not get overwritten |
| Namespaces | <ns:tag xmlns:ns="..."> — the prefix is part of the name |
| Comments and PIs | Usually ignored, but some formats use them |
| Character data (CDATA) | <![CDATA[raw content]]> — should be treated as text |
There's no single official XML-to-JSON mapping. The most widely adopted convention (popularised by the Badgerfish and xml2js formats) is:
- Attributes → stored under a
@attributes(or$) key - Text content → stored under a
#text(or_) key - Repeated sibling elements → collected into an array
- Single-child elements → kept as an object (some libraries optionally force arrays)
The conversion algorithm
- Parse the XML into a tree (DOM or event-based)
- Walk the tree recursively
- For each element, build a JS/Python/Go/PHP object:
- Add each attribute as
@attributes.<name>: value - Add each child element as a property (accumulate duplicates into an array)
- If the element has only text content, store it as
#textor collapse the object to a plain string
- Add each attribute as
- Serialize the result to JSON
JavaScript
Node.js doesn't include an XML parser in its standard library, but the fast-xml-parser package (npm, ~600 kB, zero dependencies) is the de-facto choice:
import { XMLParser } from "fast-xml-parser";
const xml = `
<books>
<book id="1">
<title>Clean Code</title>
<author>Robert Martin</author>
</book>
<book id="2">
<title>The Pragmatic Programmer</title>
<author>Hunt & Thomas</author>
</book>
</books>`;
const parser = new XMLParser({
ignoreAttributes: false, // keep id="1" etc.
attributeNamePrefix: "@_", // prefix to distinguish attrs from elements
isArray: (name) => // force these tags to always be arrays
["book"].includes(name),
});
const result = parser.parse(xml);
console.log(JSON.stringify(result, null, 2));
Output:
{
"books": {
"book": [
{ "@_id": "1", "title": "Clean Code", "author": "Robert Martin" },
{ "@_id": "2", "title": "The Pragmatic Programmer", "author": "Hunt & Thomas" }
]
}
}
In a browser, use DOMParser with a manual tree-walk:
function xmlNodeToJson(node) {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.nodeValue.trim();
return text ? text : null;
}
const obj = {};
// attributes
for (const attr of node.attributes ?? []) {
obj[`@${attr.name}`] = attr.value;
}
// children
for (const child of node.childNodes) {
if (child.nodeType === Node.COMMENT_NODE) continue;
const key = child.nodeName === "#text" ? "#text" : child.nodeName;
const val = xmlNodeToJson(child);
if (val === null) continue;
if (key in obj) {
obj[key] = [].concat(obj[key], val); // duplicate → array
} else {
obj[key] = val;
}
}
// collapse plain-text elements
const keys = Object.keys(obj);
if (keys.length === 1 && keys[0] === "#text") return obj["#text"];
return obj;
}
const doc = new DOMParser().parseFromString(xml, "text/xml");
const json = { [doc.documentElement.nodeName]: xmlNodeToJson(doc.documentElement) };
console.log(JSON.stringify(json, null, 2));
Python
The standard library's xml.etree.ElementTree is sufficient for most XML. For full attribute + namespace support, use xmltodict (pip):
import xmltodict
import json
xml = """
<books>
<book id="1">
<title>Clean Code</title>
<author>Robert Martin</author>
</book>
<book id="2">
<title>The Pragmatic Programmer</title>
<author>Hunt & Thomas</author>
</book>
</books>
"""
data = xmltodict.parse(xml, force_list={"book"})
print(json.dumps(data, indent=2))
force_list={"book"} ensures book is always a list — even when there's only one element. Without it, a single <book> would parse as a dict and two would parse as a list, which breaks consuming code.
Using the standard library only:
import xml.etree.ElementTree as ET
import json
def elem_to_dict(elem):
result = {}
# attributes
if elem.attrib:
result["@attributes"] = dict(elem.attrib)
# text
text = (elem.text or "").strip()
if text:
result["#text"] = text
# children
for child in elem:
key = child.tag
val = elem_to_dict(child)
if key in result:
existing = result[key]
result[key] = existing if isinstance(existing, list) else [existing]
result[key].append(val)
else:
result[key] = val
# collapse if only text
if list(result.keys()) == ["#text"]:
return result["#text"]
return result
root = ET.fromstring(xml.strip())
print(json.dumps({root.tag: elem_to_dict(root)}, indent=2))
Go
Go's encoding/xml package can unmarshal into a map[string]interface{} with some work. The github.com/basgys/goxml2json library handles the common case cleanly:
package main
import (
"fmt"
"strings"
xml2json "github.com/basgys/goxml2json"
)
func main() {
xmlStr := `
<books>
<book id="1"><title>Clean Code</title><author>Robert Martin</author></book>
<book id="2"><title>The Pragmatic Programmer</title><author>Hunt & Thomas</author></book>
</books>`
json, err := xml2json.Convert(strings.NewReader(xmlStr))
if err != nil {
panic(err)
}
fmt.Println(json.String())
}
For a zero-dependency approach using only the standard library:
package main
import (
"encoding/json"
"encoding/xml"
"fmt"
"io"
"strings"
)
type Node struct {
XMLName xml.Name
Attrs []xml.Attr `xml:",any,attr"`
Content []byte `xml:",innerxml"`
Children []Node `xml:",any"`
}
func (n Node) MarshalJSON() ([]byte, error) {
m := map[string]interface{}{}
for _, a := range n.Attrs {
m["@"+a.Name.Local] = a.Value
}
for _, c := range n.Children {
key := c.XMLName.Local
val := interface{}(c)
if existing, ok := m[key]; ok {
switch e := existing.(type) {
case []interface{}:
m[key] = append(e, val)
default:
m[key] = []interface{}{existing, val}
}
} else {
m[key] = val
}
}
if len(m) == 0 {
return json.Marshal(strings.TrimSpace(string(n.Content)))
}
return json.Marshal(m)
}
func main() {
xmlStr := `<books><book id="1"><title>Clean Code</title></book></books>`
dec := xml.NewDecoder(strings.NewReader(xmlStr))
var root Node
if err := dec.Decode(&root); err != nil && err != io.EOF {
panic(err)
}
out, _ := json.MarshalIndent(map[string]interface{}{root.XMLName.Local: root}, "", " ")
fmt.Println(string(out))
}
PHP
PHP's SimpleXML is the easiest path. It doesn't handle duplicate elements gracefully, but works for most feeds and configs:
<?php
function xmlToArray(SimpleXMLElement $xml): mixed {
$result = [];
// attributes
foreach ($xml->attributes() as $key => $val) {
$result['@' . $key] = (string) $val;
}
// children
foreach ($xml->children() as $tag => $child) {
$val = xmlToArray($child);
if (isset($result[$tag])) {
// already exists — make it an array
$result[$tag] = array_merge(
is_array($result[$tag]) && !array_key_exists('@', $result[$tag])
? $result[$tag]
: [$result[$tag]],
[$val]
);
} else {
$result[$tag] = $val;
}
}
// text-only node
if (empty($result)) {
return trim((string) $xml) ?: null;
}
$text = trim((string) $xml);
if ($text !== '') {
$result['#text'] = $text;
}
return $result;
}
$xmlString = <<<XML
<books>
<book id="1"><title>Clean Code</title><author>Robert Martin</author></book>
<book id="2"><title>The Pragmatic Programmer</title><author>Hunt & Thomas</author></book>
</books>
XML;
$xml = simplexml_load_string($xmlString);
$array = [
$xml->getName() => xmlToArray($xml),
];
echo json_encode($array, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
For better namespace handling, use DOMDocument with DOMXPath.
Edge-case quick reference
| Input XML pattern | What to do |
|---|---|
<item> appears once |
Wrap in array if consuming code expects an array — use force_list / isArray option |
Attribute id="1" |
Store as @id or @_id to distinguish from child elements |
<p>Hello <b>world</b></p> |
Mixed content — store text node as #text, keep children |
<ns:tag xmlns:ns="uri"> |
Strip prefix or include it — depends on whether namespace matters to consumers |
<![CDATA[raw]]> |
Most parsers expand CDATA transparently — treat as text |
Empty element <br /> |
Map to null, "", or {} — pick one and be consistent |
Numeric text <count>5</count> |
Most parsers keep as string; coerce manually if needed |
Common pitfalls
1. Losing attributes. By default, many quick conversions use text() content only and silently drop attributes. Always check if your XML uses attributes before choosing a library or snippet.
2. Duplicate elements collapsing. When two <item> elements appear, a naive object builder overwrites the first with the second. Always accumulate duplicates into an array.
3. Single-element arrays. Some feeds have one <item> today and ten tomorrow. If you don't force-list those elements, your consuming code breaks when the count changes. Use force_list / isArray options, or normalise after parsing.
4. Namespace prefixes. <atom:entry> and <entry> may refer to the same element depending on xmlns declarations. Stripping prefixes naively can cause key collisions.
5. Encoding issues. XML declares its encoding in the prolog (<?xml version="1.0" encoding="ISO-8859-1"?>). Most parsers handle this, but reading the file as bytes and then parsing as UTF-8 will corrupt non-ASCII characters.
6. Pretty-printed vs minified. Whitespace-only text nodes (\n ) can appear as #text: "\n " between elements. Always trim text content and discard blank text nodes.
Frequently asked questions
Does the conversion lose any information?
Not if you preserve attributes (@name) and text nodes (#text). The only truly lossy conversion is ignoring attributes or treating mixed content as pure text.
What's the best library for Node.js?fast-xml-parser for most work. xml2js is older but still widely used. For tiny scripts, the browser's DOMParser or Node's @xmldom/xmldom also works.
How do I handle a SOAP envelope?
Strip the <soap:Envelope> and <soap:Body> wrappers, then convert the inner payload. Focus on the namespace-local name (Body, not soap:Body) if you're stripping prefixes.
Can I round-trip JSON back to XML?
Yes, but only if you preserved the @attributes / #text structure. Most tools support JSON-to-XML as well as XML-to-JSON.
Why do some converters produce {"book": {...}} and others produce {"book": [...]}?
Whether a single element becomes an object or a one-item array depends on the library's default behaviour and whether you configure a force_list option. Pick the convention that matches your consumer's expectations and stick with it.
What about very large XML files (100 MB+)?
Use a streaming (SAX/event-based) parser instead of loading the whole document into memory. In Python, xml.etree.ElementTree.iterparse; in Go, xml.Decoder.Token(); in Node, sax or expat-stream.