Oct 3, 2026 · 4 min read
Ask how an XML diff tool works and you might expect some XML-specific algorithm — SAX event streams, DOM tree edit distance, something exotic. The real answer in our case is almost disappointing: we don’t diff XML at all. We parse both documents into plain data trees, hand them to a JSON comparison engine, and report what comes back. The interesting engineering is all in that mapping — and in why it’s the right call.
JSON has a formatting problem: the same data can serialize many ways. XML has that problem squared, because the spec itself declares several surface differences insignificant:
Attribute order doesn’t matter. These two elements are identical:
<user id="42" name="Ann"/>
<user name="Ann" id="42"/>
Whitespace between tags doesn’t matter. Indentation, line breaks between elements — all ignorable in most contexts:
<user><id>42</id><name>Ann</name></user>
<user>
<id>42</id>
<name>Ann</name>
</user>
Empty elements have two spellings. <flag/> and <flag></flag> are the same node.
A line-based diff trips on all three. And unlike JSON, there’s no XML.stringify to canonicalize the text first — pretty-printing changes whitespace inside mixed content, which can itself be meaningful. Comparing XML as text is a dead end.
The engine (@compare-xml/core) is built on fast-xml-parser with a few deliberate switches:
const xmlParser = new XMLParser({
ignoreAttributes: false, // attributes are data, not noise
attributeNamePrefix: '@', // mark them so they can't collide with child elements
parseAttributeValue: true, // id="42" becomes the number 42
});
The mapping rules are simple, but each one encodes a decision:
<user><id>42</id></user> → { user: { id: 42 } }.<item> tags under one parent → item: [ ... ]. This is where XML’s schema-free nature bites — one <item> is an object, two are an array, and the diff has to cope with a document that gained its second element mid-flight.@: id="42" → '@id': 42. Namespaces and attributes now live in the same tree as elements, addressable by the same path syntax.parseAttributeValue, numeric-looking strings become actual numbers, so id="42" and id="42" compare as numbers, not strings.Attribute order? Gone — object key order is never consulted. Inter-tag whitespace? Discarded by the parser. <flag/> versus <flag></flag>? Same parsed result. All three text-diff traps evaporate in one step.
Once both documents are data trees, comparison is a solved problem — literally: compareXML parses both sides and delegates to compareJSON from @compare-json/core, the engine behind our JSON tool. That single decision buys XML diff everything the JSON engine already had:
users.user[2].@id rather than “line 847”, with a type of added, deleted, or valueChanged.<item> list? Unordered mode reports nothing. Insert one element at the top? LCS mode reports one insertion instead of every index shifting.There’s one deliberate loss in translation: JSON’s typeChanged doesn’t survive the trip, because XML has no types to change — everything in the source document is text. The mapping layer filters the diff types down to the three XML actually produces.
XML has one failure mode JSON doesn’t: a document can be unparseable in interesting ways — an unclosed tag, a mismatched namespace, an illegal entity. So the engine validates first with XMLValidator, which reports the exact line and column of the error, and only then parses. A comparison that starts from a broken document would be a diff of garbage; better to say “line 12, column 34” and stop.
Because the diff result is the same flat, path-addressed list the JSON tool produces, the entire UI layer is shared thinking: a sidebar that groups differences by path, a side-by-side Monaco view with highlights mapped back to source ranges, and a git-diff view for people who think in +/-. One computation, three presentations — and none of them knows the input was ever XML.
Semantic XML diff is mostly a refusal to write an XML diff:
@-prefixed keys and repeated elements as arrays.The XML-specific work shrinks to where it belongs — parsing, mapping, validation — and everything downstream is shared, tested, and free.
Try it on your own data: compare two XML documents now.