There is no canonical mapping from XML to JSON, because XML has attributes, namespaces, ordering and mixed content, and JSON has none of them. Every converter invents conventions, and the interesting failures are where those conventions leak.
Most of the time this does not matter. You pull a feed, convert it, read three fields, and move on. The trouble starts when the source is a real system rather than an example, at which point a document with one item parses differently from a document with two, and code that worked in testing breaks the first time a customer has a single order.
This covers the four places conversions actually lose information and the practical handling for each.
Table of contents
- Attributes have nowhere obvious to go
- The single-element array problem
- Namespaces and ordering
- Everything is a string
- Doing it properly
- How this fits the rest of the stack
- FAQ
Attributes have nowhere obvious to go
XML distinguishes attributes from child elements. JSON has only keys, so a converter has to invent a convention for one of them.
<book id="42" lang="en">
<title>The Manual</title>
<price currency="GBP">29.99</price>
</book>
The dominant convention prefixes attributes and gives element text a reserved key:
{
"book": {
"@id": "42",
"@lang": "en",
"title": "The Manual",
"price": { "@currency": "GBP", "#text": "29.99" }
}
}
Note what happened to price. As a plain element it would have been a string; because it carries an attribute it became an object. So the same field has a different shape depending on whether the source included an attribute, and consuming code has to handle both.
Different libraries use different prefixes: @, _, $, or a nested attributes object. Whichever your library uses, the important thing is to know it and to normalise immediately after parsing rather than letting the convention spread through your codebase.
The single-element array problem
This is the one that causes real production incidents, and it is worth understanding precisely.
XML has no concept of a list. Repetition is just repetition, and a converter working from a document alone cannot tell a one-item collection from a single value.
<!-- Two orders. -->
<orders>
<order id="1"/>
<order id="2"/>
</orders>
<!-- One order. -->
<orders>
<order id="1"/>
</orders>
// Two orders: an array, as expected.
{ "orders": { "order": [ {"@id":"1"}, {"@id":"2"} ] } }
// One order: an object. Not an array.
{ "orders": { "order": {"@id":"1"} } }
Code doing data.orders.order.map(...) works throughout development, because your test fixture has three orders, and fails in production the first time a customer has exactly one. It is a genuinely common outage.
Two fixes. Tell the converter which paths are always arrays, which most libraries support:
import { XMLParser } from "fast-xml-parser";
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@",
// Force these paths to be arrays regardless of cardinality.
isArray: (name, jpath) => ["orders.order", "items.item"].includes(jpath),
});
Or normalise defensively at every collection boundary:
const asArray = (v) => (v === undefined || v === null ? [] : Array.isArray(v) ? v : [v]);
for (const order of asArray(data.orders?.order)) { /* ... */ }
The second is uglier and it never breaks, which on a feed you do not control is the more important property.
Namespaces and ordering
Namespaces exist so two schemas can use the same element name without collision. JSON has no equivalent, so converters either flatten prefixes into the key name, strip them entirely, or keep them as literal text.
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body><getResult xmlns="urn:example">42</getResult></soap:Body>
</soap:Envelope>
Stripping prefixes is convenient and lossy: two genuinely different elements that happened to share a local name collapse into one. Keeping them makes keys like soap:Body, which is accurate and awkward to work with. Either is defensible; picking without knowing you picked is not.
Ordering is the other loss. XML sequence is meaningful, particularly in document formats where the order of paragraphs and figures is the content. JSON object keys are unordered by specification, and converting a sequence of differently-named siblings into an object destroys that order irrecoverably.
Mixed content, meaning text interleaved with elements, is the same problem in sharper form:
<p>Some <b>bold</b> text and <i>italic</i> too.</p>
Every general-purpose converter mangles this. If your XML is document-shaped rather than data-shaped, converting it to JSON is the wrong operation and you should be traversing the tree directly instead.
Everything is a string
XML carries no types without a schema, so <count>42</count> is the string "42" and nothing in the document says otherwise.
Most converters offer automatic type coercion, and it is a trap worth understanding before enabling it.
// With coercion enabled, these all change meaning:
"007" -> 7 // leading zeros gone from a product code
"1.10" -> 1.1 // trailing zero gone from a version string
"true" -> true // fine, unless it was a literal string
"0123456789012345678" -> 1.2345678901234568e+17 // precision lost
"+44 20 1234" -> could become a number in some parsers
The identifier cases are the dangerous ones. A product code with leading zeros, a long numeric identifier that exceeds double precision, or a version string that looks like a decimal will each be silently corrupted, and the corruption survives into whatever you write next.
The safe default is to leave coercion off and convert explicitly where you know the type. It is more code and it never surprises you.
import xmltodict
# Everything stays a string; convert deliberately where you know the type.
doc = xmltodict.parse(xml_text)
count = int(doc["root"]["count"])
Doing it properly
A short set of rules that avoid the whole category.
- Never convert then guess the shape. Convert into your own typed structure immediately, with explicit handling for arrays and types, so the converter’s conventions do not leak past one function.
- Force arrays for known collections, or normalise every collection access with an as-array helper. Do not rely on the source always having more than one item.
- Leave type coercion off and convert explicitly. Identifiers in particular should stay strings.
- Decide about namespaces deliberately. Strip them if the document uses one schema; keep them if it composes several.
- Do not convert document-shaped XML at all. Mixed content and meaningful ordering do not survive. Traverse the tree.
- Test with a one-item document. This single test case catches the most common production failure in this whole area.
For a genuinely large document, streaming matters too. Loading a multi-gigabyte feed into memory to convert it is how a background worker gets killed by the kernel. A pull parser processing records one at a time keeps memory flat regardless of input size, and on a small plan that is the difference between a job that completes and one that is terminated.
The services documentation covers the runtimes available on RunxBuild for a worker doing this kind of processing, and running the conversion as a background job rather than inside a request is worth doing for the same reason it always is.
How this fits the rest of the stack
Feed processing is one of those workloads whose cost lands on memory rather than CPU, and the plan that comfortably serves your web traffic may not survive one large document parsed into memory. The RunxBuild hosting calculator shows the RAM at each step of the ladder, which is the number to look at before deciding whether a streaming parser is an optimisation or a requirement.
Useful related references:
- Should package-lock.json Be Committed?
- JSON to YAML: Tools, When to Convert, and the Gotchas
- n8n Workflow JSON: The Structure, and How to Edit It by Hand
- Custom domains and certificates on RunxBuild
FAQ
Is XML to JSON conversion lossless?
No. XML has attributes, namespaces, meaningful element ordering and mixed content, and JSON has none of these. Every converter invents conventions to bridge the gap, and information is lost wherever those conventions cannot represent the original faithfully.
Why does my array become an object when there is only one item?
Because XML has no list construct, so a converter cannot distinguish a one-item collection from a single value by looking at the document. Configure your parser to force known paths to arrays, or normalise every collection access with a helper that wraps a single value in an array.
Should I enable automatic type conversion?
Usually not. It silently corrupts product codes with leading zeros, version strings that look like decimals, and numeric identifiers longer than double precision can represent. Leave everything as strings and convert explicitly where you know what the type should be.
How are XML attributes represented in JSON?
By convention rather than by standard. Most libraries prefix attribute keys, commonly with an at sign or underscore, and put element text under a reserved key. This means an element gains an object shape when it carries an attribute and is a plain string when it does not.
What happens to XML namespaces?
Converters either flatten the prefix into the key name, strip namespaces entirely, or keep the prefixed name as literal text. Stripping is convenient and lossy, since two different elements sharing a local name collapse into one. Choose deliberately based on whether the document composes multiple schemas.