So you've got an XML payload and you need JSON. Sounds trivial until you realise XML and JSON have fundamentally different data models. XML has attributes, mixed content, namespaces, and implicit arrays. JSON has none of that baggage.
A naive string-replacement or a default xmltodict.parse() call will technically "work", but your resulting JSON will be inconsistent, break on edge cases, and drive you mad two weeks from now when the payload changes. Here's how to do it properly.
Why XML → JSON Isn't Just Find & Replace
Before we get into the code, understand the core structural mismatches:
| XML Concept | JSON Equivalent | Problem |
|---|---|---|
Attributes (id="123") | Properties | JSON has no attribute concept |
| Single vs. multiple child elements | Value vs. Array | Single item = string, multiple = array. Inconsistent |
| All text is a string | Typed values | "true", "42" need real type inference |
Namespaces (soap:Body) | No equivalent | Naming conflicts without careful handling |
These mismatches cause the majority of bugs in XML-to-JSON pipelines. Let's tackle each one.
Challenge 1: Handling Attributes
XML attributes don't have a natural home in JSON. The most portable convention is the @ prefix:
<user id="123" role="admin">John Doe</user>
Converts to:
JSONJSON Output{ "user": { "@id": "123", "@role": "admin", "#text": "John Doe" } }
Using fast-xml-parser in Node.js:
JavaScriptimport { XMLParser } from 'fast-xml-parser'; const parser = new XMLParser({ ignoreAttributes: false, // Don't throw away attributes! attributeNamePrefix: "@", // Prefix attributes with @ textNodeName: "#text", // Name for the text content parseAttributeValue: true // Auto-convert "123" → 123 }); const json = parser.parse(xmlString);
Don't set ignoreAttributes: true (which is the default in many libraries). You'll silently lose data like IDs, currency codes, and statuses that are often encoded as attributes in real-world XML.
Challenge 2: Arrays The Silent Killer
This is the one that bites everyone. XML doesn't differentiate between a collection with one item and a collection with many:
XML<!-- One product --> <products> <product>Widget A</product> </products> <!-- Multiple products --> <products> <product>Widget A</product> <product>Widget B</product> </products>
Without explicit configuration, most parsers turn these into different types:
JSON// One item → string (bad!) { "products": { "product": "Widget A" } } // Multiple items → array (what you'd expect) { "products": { "product": ["Widget A", "Widget B"] } }
This silently breaks your frontend code whenever a single-item edge case comes through. Your code does products.product.map(...) and suddenly crashes.
The Fix: Declare Your Array Tags
JavaScriptconst parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@", isArray: (tagName) => { // These tags should ALWAYS be arrays, even with a single child const alwaysArrayTags = ['product', 'item', 'user', 'order', 'tag', 'role']; return alwaysArrayTags.includes(tagName); } });
If you don't know the schema upfront, write a post-processing step that wraps known plural parent containers:
JavaScriptfunction normalizeArrays(obj, arrayKeys = ['products', 'items', 'users', 'orders']) { if (typeof obj !== 'object' || obj === null) return obj; for (const key of Object.keys(obj)) { if (arrayKeys.includes(key) && !Array.isArray(obj[key])) { obj[key] = [obj[key]]; // Force it to be an array } normalizeArrays(obj[key], arrayKeys); } return obj; }
Challenge 3: Type Inference
XML treats everything as text. That means "true", "false", "42", and "3.14" are all strings unless you explicitly parse them.
The parseAttributeValue: true option in fast-xml-parser handles attributes, but element text content also needs care:
JavaScriptfunction parseTypedValue(value) { if (value === "true") return true; if (value === "false") return false; if (value === "null" || value === "") return null; const num = Number(value); if (!isNaN(num) && value.trim() !== "") return num; return value; // Fallback: keep as string }
Or configure it directly in the parser:
JavaScriptconst parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@", parseAttributeValue: true, parseTagValue: true, // Also parses element text content trimValues: true // Trim whitespace from values });
Real-World Scenario: SOAP to REST Migration
One of the most common XML-to-JSON jobs is stripping SOAP envelope overhead when migrating to REST. Here's the pattern:
Incoming SOAP XML:
XML<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"> <soap:Body> <User id="123"> <Name>John Doe</Name> <Email>john@example.com</Email> <Roles> <Role>admin</Role> <Role>user</Role> </Roles> </User> </soap:Body> </soap:Envelope>
Target REST JSON:
JSON{ "user": { "id": 123, "name": "John Doe", "email": "john@example.com", "roles": ["admin", "user"] } }
Here's a clean Node.js transformation:
JavaScriptimport { XMLParser } from 'fast-xml-parser'; function soapToRest(soapXml) { const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@", parseAttributeValue: true, parseTagValue: true, isArray: (name) => name === 'Role' }); const parsed = parser.parse(soapXml); // Drill past the SOAP envelope wrapper const rawUser = parsed?.['soap:Envelope']?.['soap:Body']?.User; if (!rawUser) throw new Error("Could not locate User in SOAP body"); return { user: { id: rawUser["@id"], name: rawUser.Name, email: rawUser.Email, roles: rawUser.Roles.Role.map(r => r.toLowerCase()) } }; }
Real-World Scenario: XML Config to JSON Config
If you're migrating an app from XML-based config (Spring, Maven, etc.) to JSON, you need proper type inference or your app will fail to read port as a number:
XML Config:
XML<database host="localhost" port="5432"> <name>myapp</name> <ssl>true</ssl> <pool_size>10</pool_size> </database>
Expected JSON Config:
JSON{ "database": { "host": "localhost", "port": 5432, "name": "myapp", "ssl": true, "pool_size": 10 } }
Tools and Libraries
JavaScript/Node.js
JavaScriptimport { XMLParser } from 'fast-xml-parser'; const parser = new XMLParser({ ignoreAttributes: false, parseAttributeValue: true, attributeNamePrefix: "@" }); const json = parser.parse(xmlString);
Python
Pythonimport xmltodict import json with open('file.xml', 'r') as f: xml_content = f.read() json_data = xmltodict.parse(xml_content) print(json.dumps(json_data, indent=2))
Online Tools
Use our XML to JSON converter for quick conversions. It processes files locally for privacy.
Conclusion
Pick one attribute strategy and stick with it. The @ prefix works well. Handle arrays consistently always use arrays for collections. Implement type conversion to preserve numbers and booleans. Test with real data and handle errors gracefully.
For the reverse process, read our JSON to XML Conversion Guide. Check out Essential Developer Tools 2026 for more utilities.