Engineering

How XML Diff Works Under the Hood: From Markup to Data Trees

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.

Text diff fails for XML even harder than for JSON

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 mapping: XML becomes a data tree

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:

  • An element with children becomes an object: <user><id>42</id></user> → { user: { id: 42 } }.
  • Repeated sibling elements become an array: three <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.
  • Attributes become keys prefixed with @: id="42" → '@id': 42. Namespaces and attributes now live in the same tree as elements, addressable by the same path syntax.
  • Text content becomes a value — with 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.

Diffing for free

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:

  • Path-addressed results — differences come back as users.user[2].@id rather than “line 847”, with a type of added, deleted, or valueChanged.
  • Three array comparison strategies — repeated elements are arrays, so positional pairing, LCS-based minimal edits, and unordered membership matching all apply. Reorder your <item> list? Unordered mode reports nothing. Insert one element at the top? LCS mode reports one insertion instead of every index shifting.
  • Case-insensitive options — key and value case folding carry straight over.

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.

Validation before comparison

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.

Same output, three views

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.

Summary

Semantic XML diff is mostly a refusal to write an XML diff:

  1. Parse both documents into data trees, with attributes as @-prefixed keys and repeated elements as arrays.
  2. Hand the trees to a JSON tree comparator with LCS and unordered array strategies.
  3. Map the resulting path-addressed differences back onto the UI.

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.