Skip to content
datahacks.dev
XML

Diffing XML Ignoring Attribute Order and Layout Noise

Make a line diff of two XML files show real changes only, by canonicalizing both sides, then use xmldiff when you need structural edit actions.

6 min read
Summarize in AI
Flat illustration of paired angle-bracket tags wrapped around a document, with the XML logo.

A plain diff on two XML files is noisy for reasons that have nothing to do with the data. One exporter writes id before status and another writes them the other way round. One indents with two spaces, the other with a tab, the other not at all. You wanted to know which orders changed and you got a screen of red and green. This post makes the diff small enough to read, in two ways: normalize both sides and use the tool you already have, or compare the trees.

Two files, one real change

The first file has two orders. The second has the same two, one with a changed total, plus a new order, and the writer put attributes in a different order and added no layout at all:

o1.xmlXML
<orders><order id="1" status="paid"><total>10.00</total></order><order id="2" status="open"><total>5.00</total></order></orders>
o2.xmlXML
<orders><order status="paid" id="1"><total>12.50</total></order><order id="3" status="open"><total>7.00</total></order><order id="2" status="open"><total>5.00</total></order></orders>

Both files are one line each, so diff o1.xml o2.xml reports one line removed and one added. That is accurate and useless. Pretty printing each side first gives line-level output, but attribute order still shows up as a changed line for order 1 even though only the total changed.

Normalize, then diff

The recipe has two steps, and each fixes a different kind of noise. Canonical XML (why diffs need it) sorts attributes, fixes quotes and drops the declaration. xmllint --format then puts each element on its own line with fixed indentation. Chaining them and feeding the results to diff through process substitution avoids temporary files:

Shell
diff <(xmllint --c14n o1.xml | xmllint --format -) \
     <(xmllint --c14n o2.xml | xmllint --format -)
Text
4c4,7
<     <total>10.00</total>
---
>     <total>12.50</total>
>   </order>
>   <order id="3" status="open">
>     <total>7.00</total>

Now the diff shows what happened: the first order's total changed from 10.00 to 12.50 and a new order with id 3 appeared. The attribute order change for order 1 is gone. Note how diff reports the insertion: it is a line diff, so it shows the closing tag and opening tag of the neighbors as part of the hunk. That is a limit of the approach rather than a bug, and it is why a structural tool exists.

Put the two steps in a function in your shell profile or a small script, because you will use them often:

xdiff.shShell
#!/bin/bash
# usage: xdiff.sh old.xml new.xml
norm() { xmllint --c14n "$1" | xmllint --format -; }
diff -u <(norm "$1") <(norm "$2")

Process substitution is a bash feature, so run the script with bash and change the first line to #!/bin/bash if your sh is a stricter shell. If the files can contain mixed content, such as paragraphs with inline markup, pretty printing can add whitespace that changes the text. Skip --format and diff the canonical form directly, accepting long lines, or use git diff --word-diff.

Comparing trees with xmldiff

When you want the changes as operations on the tree, not lines, xmldiff computes an edit script. Install it with pip install xmldiff. Run on the same two files:

Shell
xmldiff o1.xml o2.xml
Text
[insert, /orders[1], order, 0]
[insert-attribute, /orders/order[1], id, "1"]
[insert-attribute, /orders/order[1], status, "paid"]
[update-attribute, /orders/order[2], id, "3"]
[update-attribute, /orders/order[2], status, "open"]
[insert, /orders/order[1], total, 0]
[update-text, /orders/order[1]/total[1], "12.50", null]
[update-text, /orders/order[2]/total[1], "7.00", "10.00"]

This reads wrong, and it is worth understanding why. The tool did not match order 1 in file one with order 1 in file two. It saw a new first child, a second child whose id changed from 1 to 3 and a total that changed from 10.00 to 7.00. Tree diff algorithms match nodes by similarity and position, and when records move or are inserted near the start, the best-scoring match can be a different record. Attribute values did not act as identity because xmldiff does not know that id is a key.

You can tell the tool what identifies an element with --unique-attributes id. I tried it, and the result was different but not clearer: the new order was reported as an insert, and the two totals were reported as moves and text updates between the wrong parents. The tool matches on similarity of subtrees, and on records this small, with identical-looking total elements, similarity is a weak signal. The lesson is not that xmldiff is broken. It is that a tree diff of repetitive records is hard to read, and for records with a key the line diff of normalized files is the clearer report.

Sort, then compare

If the order of records does not carry meaning, sort both files by the record key. This stylesheet copies everything and sorts the order children by numeric id:

sort.xslXML
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
  <xsl:output indent="yes"/>
  <xsl:strip-space elements="*"/>
  <xsl:template match="@*|node()"><xsl:copy><xsl:apply-templates select="@*|node()"/></xsl:copy></xsl:template>
  <xsl:template match="orders">
    <xsl:copy><xsl:apply-templates select="order"><xsl:sort select="@id" data-type="number"/></xsl:apply-templates></xsl:copy>
  </xsl:template>
</xsl:stylesheet>
Shell
xsltproc sort.xsl o2.xml
Text
<?xml version="1.0"?>
<orders>
  <order status="paid" id="1">
    <total>12.50</total>
  </order>
  <order id="2" status="open">
    <total>5.00</total>
  </order>
  <order id="3" status="open">
    <total>7.00</total>
  </order>
</orders>

The orders are now in id order. Run the sort on both files, then the canonicalize-and-format diff from earlier, and the output shows the changed total for order 1 and the added order 3 in sequence. Sorting is the single biggest improvement for a feed whose producer does not guarantee an order (sorting XML elements for stable output has the variants). I also fed the two sorted files to xmldiff. It still printed text updates against shifted positions, so I would not rely on it for lists of near-identical records.

A short checklist

  1. Decide whether element order is meaningful. If it is not, sort by key.

  2. Canonicalize both files so attributes and quoting match.

  3. Format both files the same way so each element sits on its own line.

  4. Run diff -u for a patch you can read, or xmldiff for edit actions.

  5. For a count of how many records changed, use a keyed comparison in a script (comparing two XML documents to find differences shows one).

For a quick look without the shell, the text diff tool takes two pasted blocks, and the XML formatter does step three. The tool is described in the xmldiff documentation and the stylesheet language in the XSLT 1.0 specification. The cluster hub is comparing and versioning XML.

When a text diff of XML is the wrong tool

The question is "is it valid", not "what changed". If you need to know whether the new file still follows the contract, validate it against the schema (validating XML against an XSD schema). A diff will not tell you.

The file is huge. Normalizing a 2 GB file in memory is not an option. Stream both files into keyed records, such as a CSV of id and a hash of each record, and compare those (comparing and versioning XML describes the hash approach).

Mixed content or documents. For prose with inline markup, line diffs and tree diffs both struggle. Use a word diff of the extracted text.

Records have no key. Without something that identifies a record, any matcher guesses. Fix that upstream, or accept that the diff is a hint.

Also inTooling
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.