XML to JSON: Attributes, Repetition, and the Single-Element Array Trap
Published 7/20/2026 · 15 min read · Developer tools
Daniel Okonkwo — Front-end developer and tech writer at Allin
Web performance · File formats
Checked against 4 sources
XML has no way of saying that an element is a list. The documents <items><item>a</item></items> and <items><item>a</item><item>b</item></items> differ only in how many children exist, so a converter reading one of them cannot know whether item is a repeatable element that happens to have one instance. This tool takes the usual route: one child returns {"items":{"item":"a"}}, a string, and two return {"items":{"item":["a","b"]}}, an array. Any consumer that writes items.item[0] works until the day a list has one element, and then reads the letter a — the first character of the string — instead of the item. The same document can produce both shapes at once: <r><g><i>1</i><i>2</i></g><g><i>3</i></g></r> returns g as an array of two objects, the first with i as an array of two and the second with i as a plain string. Four conventions exist to address this. Declare the shape in a schema, which is the only place cardinality is ever written down; hand the converter an explicit list of paths that are always arrays; prefix attribute names so they cannot collide with element names; and reserve a key for the text of an element that also carries attributes. This tool does the last two — attributes become @name, and #text holds an element's own text, whether that element carries attributes, child elements, or both. Mixed content is kept: <p>Hello <b>world</b>!</p> returns {"p":{"#text":["Hello","!"],"b":"world"}}, one array entry per run of text. What nothing records is the interleaving — the output does not say that Hello came before <b> and the exclamation mark after it. Whitespace-only runs are dropped and the rest is trimmed unless xml:space="preserve" is in scope, in which case the spacing is kept exactly: <code xml:space="preserve"> keep </code> returns {"code":{"@xml:space":"preserve","#text":" keep "}}. Every value is a string: <n>42</n> becomes "42".
Two documents that differ only in how many children exist produce two different JSON shapes, and no converter can tell them apart without a schema. Plus what this one really does with attributes, mixed content and whitespace — and the one thing it still cannot record.
The defect is in the format, not in the converter
Write down an order with three line items in XML and you repeat the line element three times. Write down an order with one line item and you write the element once. Nothing in the document distinguishes that from an element that is simply not repeatable — there is no plural marker, no cardinality, no bracket. The XML specification defines what a well-formed document looks like and says nothing at all about how many times a child may appear; that question belongs to a schema, and a document is under no obligation to have one.
So the converter guesses, and there are only two ways to guess. Always produce an array, which makes every single-valued element an array of one and doubles the noise in the output. Or produce an array only when you see repetition, which is what almost every tool does, including this one. Feed it <items><item>a</item></items> and it returns {"items":{"item":"a"}}. Feed it <items><item>a</item><item>b</item></items> and it returns {"items":{"item":["a","b"]}}. Feed it <items></items> and it returns {"items":""} — an empty string, not an empty array, because with no children the element is treated as a leaf and its text content is nothing.
The consequence is that the shape of your JSON depends on your data, and it can change inside a single document. Convert <r><g><i>1</i><i>2</i></g><g><i>3</i></g></r> and you get g as an array of two objects: in the first, i is an array of two strings; in the second, i is the string 3. Code that walks g and then indexes i works on the first element and silently reads the character 3 on the second — not an error, not a crash, just the wrong value going into whatever comes next. This is the single most common way a working XML integration breaks in production, and it breaks on the day the data gets smaller, not bigger.
The four conventions that exist, and the two this tool uses
The first is a schema. XML Schema is the only place in the whole stack where cardinality is written down: an element declaration carries minOccurs and maxOccurs, and maxOccurs greater than one is exactly the statement that this element is a list. A converter with the schema in hand can produce an array of one, correctly, for a document that happens to contain one child. Without the schema, that information does not exist anywhere in the file, and no amount of cleverness recovers it.
The second is a list of always-array paths handed to the converter by whoever knows the data. Most serious XML libraries accept one — you name order.lines.line and it becomes an array whatever the count. It is a schema in miniature, written once by a human who knows the answer, and it is the pragmatic fix when no formal schema exists. This tool has no such option, so if you are consuming its output in code, the defensive form is to normalise before you use it: coerce the value to an array yourself if it is not one already, and then index.
The third and fourth are about keys, and this tool uses both. Attributes are prefixed with @, so <book id="1"><title>Dune</title></book> returns {"book":{"@id":"1","title":"Dune"}} and an attribute can never collide with a child of the same name — <book title="A"><title>B</title></book> keeps both, as @title and title. And #text carries an element's own text whenever that text has to share the object with something else, attributes or child elements alike: <book id="1" lang="en">Dune</book> returns {"book":{"@id":"1","@lang":"en","#text":"Dune"}}. An element with no attributes and no element children skips the wrapper entirely and becomes its text directly, which is why <title>Dune</title> is the string Dune and not an object with one key.
Mixed content lands in #text, and the order does not
Mixed content is an element whose children are a mixture of text and other elements — a paragraph with a bold word in the middle, a description with an inline link, a legal clause with an emphasised term. XML supports it and uses it constantly; it is most of what document-oriented XML is for. JSON has no natural place to put it, because an object key can hold the bold word but there is nowhere to record that the word sat between two runs of text.
This converter resolves the problem by collecting the text into #text. Feed it <p>Hello <b>world</b>!</p> and it returns {"p":{"#text":["Hello","!"],"b":"world"}}: one entry per run of text, in document order, with the markup that split them sitting beside the array under its own key. A single run stays a plain string rather than an array of one, so <r>lead<a>1</a></r> gives "#text":"lead". Attributes change nothing: <p id="1">Hello <b>x</b> tail</p> returns {"p":{"@id":"1","#text":["Hello","tail"],"b":"x"}}. Nothing is thrown away, so an ONIX record, a DocBook fragment or an RSS description containing markup keeps its prose. What the shape cannot tell you is where the markup sat: nothing in that object says Hello came before <b> and the exclamation mark after it, and swapping the two runs in the source produces identical JSON. If the sequence carries meaning — a redline, a transcription, anything where the inline element marks a position in the sentence — keep the paragraph as a string of XML instead of converting it.
Whitespace is handled in two layers. A run that is nothing but spaces or newlines between two tags is layout rather than content, so it is dropped — which is why a pretty-printed document does not fill your JSON with blank strings — and the text that survives is trimmed, so <code> indented line</code> comes back as indented line. XML has an attribute whose entire purpose is to say do not do that, and the converter now obeys it: <code xml:space="preserve"> keep </code> returns {"code":{"@xml:space":"preserve","#text":" keep "}}, spaces and all. It inherits the way the specification says it should, so a preserve on an ancestor protects every descendant until one of them declares xml:space="default" again.
Order, namespaces and types: three more things that do not survive
Document order between different element names is lost. <r><a>1</a><b>x</b><a>2</a></r> returns a as an array of 1 and 2 and b as x, which is correct as far as it goes — the two a elements are collected even though they are not adjacent — but the fact that b sat between them is gone. In an object, keys have no order that a consumer is entitled to rely on, so there is nowhere to record it. For record-shaped XML that does not matter. For anything where sequence carries meaning — a workflow log, a change history, an interleaved narrative — it matters a great deal, and the conversion is lossy in a way you will not see by comparing sizes.
Namespaces are carried as text rather than understood. A prefixed child keeps its prefix in the key: <r xmlns:ns="http://example.com"><ns:a>1</ns:a></r> gives the key ns:a, and the declaration itself appears as the attribute @xmlns:ns. A default namespace is declared as @xmlns and then the children lose all trace of it, so an element a in a namespace and an element a in no namespace produce the same key. If two vocabularies are merged in one document and both use the same local name, you get a collision the converter is blind to. The prefix is also an author's choice, not part of the identity of the element, so the same document reserialised with a different prefix produces different JSON keys for identical data.
Types are not guessed, and here the tool is right. <a><n>42</n><f>1.0</f><z>007</z><t>true</t></a> returns four strings, not a number, a float, a padded string and a boolean. An XML document without a schema has no types either — everything is character data — so inventing them would be inventing information. The practical consequence is that you will be comparing to "true" rather than to true, and doing your own conversion where you need a number. That is the correct trade: string to number is a decision you can make with knowledge of the field, and the converter has none.
What this converter actually refuses
The parser underneath is the browser's own, and it is strict in the right way: <br> alone is rejected, an unclosed tag is rejected, two root elements are rejected, and a duplicated attribute is rejected. Strictness is the point of using an XML parser rather than an HTML one, and all of that is correct behaviour.
Detecting the failure is harder than it looks, because a browser parser does not throw. It returns a document with an element named parsererror grafted into it, and the caller is expected to go looking. Matching the name alone is the obvious implementation and the wrong one: it refuses any valid document that carries a parsererror element of its own, which is exactly what a build log or a validation report contains. The engines do not even agree on where the marker goes — Firefox roots the whole document at one in its own error namespace, while Blink and WebKit inject one in the XHTML namespace part-way down and leave your root element in place. So the tool asks the engine rather than guessing: once per session it parses something deliberately broken, reads the namespace of the marker that comes back, and from then on looks only there. <log><parsererror>none</parsererror><n>1</n></log> converts, and returns {"log":{"parsererror":"none","n":"1"}}.
| XML | JSON returned | What it tells you |
|---|---|---|
| <items><item>a</item></items> | {"items":{"item":"a"}} — a string | One child is not a list; indexing [0] returns the first character |
| <items><item>a</item><item>b</item></items> | {"items":{"item":["a","b"]}} — an array | The shape of the output depends on the count, not on the vocabulary |
| <items></items> or <items/> | {"items":""} — an empty string | Not an empty array and not null: three states collapse into one |
| <book id="1" lang="en">Dune</book> | {"book":{"@id":"1","@lang":"en","#text":"Dune"}} | Attributes get @, the element's own text gets #text, so neither can shadow a child |
| <p>Hello <b>world</b>!</p> | {"p":{"#text":["Hello","!"],"b":"world"}} — one entry per run of text | The prose survives; the interleaving does not — nothing says Hello came before <b> |
| <code xml:space="preserve"> keep </code> | {"code":{"@xml:space":"preserve","#text":" keep "}} | The instruction is obeyed and inherited; without it, every run is trimmed |
| <log><parsererror>none</parsererror><n>1</n></log> | {"log":{"parsererror":"none","n":"1"}} — converted, not refused | The failure check asks the engine which namespace its marker uses, so your own element is safe |
Frequently asked questions
- How do I write code that survives a list of one?
- Normalise before you read. Wherever your code expects a list, coerce the value first: if it is already an array keep it, otherwise wrap it in one, and treat a missing or empty value as the empty array. Three lines at the boundary of your parser, applied to every path you know is repeatable, and the single-element case stops existing for the rest of your program. Doing it at the boundary matters more than the exact code — a coercion sprinkled at every use site will eventually be forgotten at one of them, and it will be the one that runs at month end. If you are consuming the same feed regularly, write down the list of repeatable paths as a constant next to the parser, so the next person can see what shape you were expecting.
- Why not always produce an array?
- Because it is unreadable, and readability is most of what people convert XML to JSON for. Wrap every element and a record with fifteen single-valued fields becomes fifteen arrays of one, each of which has to be unwrapped by hand before it can be printed. Libraries that support the strategy usually make it opt-in per path for exactly that reason. There is also a subtler cost: an array of one is a claim that the element is repeatable, and if it is not, you have written down a fact about the vocabulary that is false. Between the two errors, a converter that guesses from the data at least never lies about the document it was given — it only tells you less than you needed.
- What happens to the text around my <b> tag?
- It goes into #text, beside the child element rather than around it. <p>Hello <b>world</b>!</p> returns {"p":{"#text":["Hello","!"],"b":"world"}} — one entry per run, in document order, and a plain string instead of an array when there is only one run. Runs that are nothing but whitespace are dropped unless xml:space="preserve" is in scope. What you do not get back is the interleaving: the JSON cannot tell you that Hello came before the bold word and the exclamation mark after it, and if a paragraph has three runs and three inline elements, reassembling the sentence from that object is guesswork. If you only need the prose, read #text and join the runs. If you need the sentence back exactly as written, do not convert the paragraph at all — keep it as a string of XML, or use a converter built for mixed content, which represents an element's children as one ordered array of text and element nodes instead of as object keys.
- Does the converter handle CDATA and entities?
- Yes, both, and correctly. A CDATA section is unwrapped and its contents become ordinary text, so <a><![CDATA[<not>markup</not>]]></a> returns the string <not>markup</not> — the angle brackets survive as characters, which is the whole point of CDATA. Named entities are resolved, so & and < come back as the ampersand and the less-than sign, and numeric character references are resolved too: café returns café. Comments and processing instructions, including the XML declaration itself, are dropped entirely, since they are not element content. None of this is the converter's own work — it is the browser's XML parser doing what the specification says — which is a good reason to prefer a real parser over a regular expression for anything more complicated than a fixed feed you control.
- My document is well-formed but the tool says it is invalid. What now?
- Check two things, in this order. First, look for HTML habits that XML does not allow — a bare <br> or <img> with no closing slash, an unescaped ampersand in a URL, a duplicated attribute on one element, or a stray character before the XML declaration such as a byte order mark that arrived through a copy and paste. Second, confirm there is exactly one root element: two siblings at the top level are a fragment, not a document, and need wrapping before anything will parse them. If both are clean and it is still refused, run it through an XML validator, which will point at a line rather than telling you only that something is wrong.
Articles you may find interesting
All guides →Related tools
This describes what these converters do today, checked by running them, not what any standard obliges a converter to do. CSV has no normative standard: RFC 4180 is Informational and describes common practice, so two correct-looking tools can disagree about the same file and neither is wrong. Flattening, type guessing and array detection are conventions, not rules. Before you run a conversion over data you cannot re-export, run it over a copy first and compare the row and column counts at both ends.
Sources
- W3C — Extensible Markup Language (XML) 1.0, Fifth Edition — section 2.1 on the single root element of a well-formed document, section 2.10 on white space handling and the xml:space attribute, and section 3.2.2 on mixed content
- W3C — W3C XML Schema Definition Language (XSD) 1.1 Part 1: Structures — minOccurs and maxOccurs on a particle: the only place in the XML stack where the cardinality of a repeated element is written down
- W3C — Namespaces in XML 1.0, Third Edition — an element's identity is its namespace name plus its local name, and the prefix is only a document-local shorthand chosen by the author
- WHATWG — HTML Standard, DOMParser and parseFromString — how an XML parse failure is reported as a document containing a parsererror element rather than as a thrown exception
Spotted a mistake in this article?