Skip to main content

How to Convert XML to JSON Without Losing Attributes or Arrays

A practical guide to converting XML to JSON: how attributes, text nodes, and repeated elements map, the single-vs-array ambiguity, and why it stays local.

Published By Li Lei
#xml #json #converter #developer

How to Convert XML to JSON Without Losing Attributes or Arrays

XML and JSON look like they should be interchangeable. Both describe nested data, both are text, both are everywhere. But they disagree about a few fundamental things, and those disagreements are exactly where a naive converter quietly corrupts your data. If you have ever pasted a SOAP response into a converter and gotten back JSON that dropped half the attributes or turned a list into a single object, you have met the gap firsthand.

This post walks through what actually happens when you convert XML to JSON: how each XML construct maps, where the genuine ambiguity lives, and why the conversion belongs in your browser rather than on someone's server.

The Three Things XML Has That JSON Doesn't

JSON has objects, arrays, strings, numbers, booleans, and null. That is the whole vocabulary. XML has three extra concepts that have no direct JSON equivalent, and every converter has to decide what to do with them.

First, attributes. An element like <book id="1" lang="en"> carries data on the tag itself, separate from its children. JSON has no notion of "data attached to a key but not a child," so a convention is required.

Second, text nodes mixed with elements. XML lets an element hold both text and child elements at once: <p>Hello <b>world</b></p>. The bare text "Hello " is a node in its own right, and it needs somewhere to live in the JSON tree.

Third, document order and repetition. XML preserves the order of sibling elements, and it happily allows ten elements with the same tag name in a row. JSON objects do not have repeated keys, so same-name siblings have to become something else.

A good converter handles all three with explicit, reversible rules. Here is how that looks in practice.

How Elements, Attributes, and Text Map

The mapping the XML to JSON converter uses is deliberately predictable:

  • Elements become keys. <book>...</book> turns into a "book" key whose value is an object.
  • Attributes get collected under a prefix. By default they sit in an @attrs object, so <book id="1"> becomes { "book": { "@attrs": { "id": "1" } } }. If you prefer the inline style that the xml2js ecosystem uses, you can flip the prefix to @ and get { "book": { "@id": "1" } } instead. Both spellings rebuild to identical XML.
  • Text nodes go under #text. The character content of an element lands in a #text key, which keeps it cleanly separated from attribute and child keys.
  • CDATA goes under #cdata. Script bodies and binary-ish blobs survive intact instead of being escaped or mangled.

The prefix matters more than it looks. Because attributes and text live under reserved keys (@attrs, #text, #cdata) that no element name can collide with, the tree stays unambiguous in both directions. That is what makes a true round-trip possible: convert XML to JSON, edit the JSON, then push it back through JSON to XML and recover the original element order, nesting, and attribute spelling.

A Worked Example

Take this small fragment of a config file:

<config env="prod">
  <database host="db1" port="5432"/>
  <flags>
    <flag>cache</flag>
    <flag>async</flag>
  </flags>
</config>

Convert it and you get:

{
  "config": {
    "@attrs": { "env": "prod" },
    "database": {
      "@attrs": { "host": "db1", "port": "5432" }
    },
    "flags": {
      "flag": ["cache", "async"]
    }
  }
}

Three rules are visible at once. The env, host, and port attributes all land under @attrs on their respective elements. The single <database> element becomes a plain object. And the two <flag> siblings collapse into a "flag" array of strings. Notice that the array key is named after the tag, not pluralized — flag, not flags plus flag, because the wrapping <flags> element is its own key.

The Single-vs-Array Ambiguity

That flag array hides the single hardest problem in XML-to-JSON conversion. When the parser meets one <flag>, it produces a scalar. When it meets two or more, it produces an array. So the shape of the output depends on the data, not the schema:

<flags><flag>cache</flag></flags>

becomes { "flags": { "flag": "cache" } } — a string, not a one-element array.

This is fine for a human reading the output and disastrous for code that expects a stable shape. Generate a TypeScript type from a feed where most items have multiple <flag> entries, then hit one item with a single flag, and your .map() throws because flag is suddenly a string. The XML schema would have told you flag is a repeatable element, but a single sample of XML cannot.

The fix is a force-array option. Turn on "Always array" and every element that could repeat is emitted as an array even when there is only one of it, so { "flag": ["cache"] } stays an array no matter the input. If you are feeding the JSON to a typed consumer, this is almost always the setting you want. If you are eyeballing the output, the default scalar form reads more naturally. There is no universally correct answer, which is precisely why it needs to be a knob rather than a hidden assumption.

Why Modern APIs Drifted Toward JSON

I spent a stretch of last year migrating an internal billing integration off a SOAP endpoint, and the XML-to-JSON step was the part I dreaded most. The vendor's WSDL described arrays of line items, but the actual responses serialized a single-item order as one bare <lineItem> element. Our first converter treated that as an object, our type checker was happy, and production broke the first time a customer ordered two of something. Switching to a converter with an explicit always-array option turned a recurring incident into a one-line setting. That experience is the whole reason I care about this seemingly pedantic mapping detail.

The broader shift is real: most new HTTP APIs ship JSON because it maps directly onto the data structures of the languages calling them. There is no attribute-versus-element decision to make, no namespace prefixes to thread through, no declaration line to parse. XML still rules in long-lived enterprise systems, Maven builds, RSS, and Android manifests, so the conversion is not going away. It just runs at the boundary now, translating legacy XML into the JSON that the rest of the stack speaks.

Keep the Conversion Local

A SOAP envelope you copied out of DevTools is not neutral text. It can carry session tokens, account identifiers, internal hostnames, and pricing. Pasting that into a random web converter means shipping it to a server you do not control, which is a quiet but real data leak.

The conversion does not need a server. Every modern browser ships a native DOMParser, the same engine that parses XML responses for fetch and XMLHttpRequest. That means correct, standards-compliant parsing runs entirely in your tab, with nothing uploaded. Your POM files, feeds, and manifests stay on your machine. When you are done, you can pipe the result straight into a JSON formatter to pretty-print it, or run the original XML through an XML formatter first if the source was minified and you want to eyeball the structure before converting.

XML and JSON will keep coexisting for years. Knowing exactly how attributes become prefixed keys, how text nodes get their own slot, and how repeated tags turn into arrays — plus when to force those arrays — is the difference between a conversion you can trust and one that breaks the first time the data shape shifts.


Made by Toolora · Updated 2026-06-13