Skip to content
datahacks.dev
XML

Comparing and Versioning XML Files in Git and in Pipelines

A tested guide to comparing XML, from canonical form and keyed record diffs to Git diffs, merges, feed shape checks and schema compatibility.

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

Comparing two XML files sounds like a text problem and is not one. The format lets a producer reorder attributes, change quote style, reindent, minify or reorder records without changing the meaning, and a byte or line comparison reports all of it as change. At the same time, versioning XML in Git, merging branches of it and evolving its schema are separate tasks with their own traps. This guide is a ladder of techniques, from the cheapest comparison to the most meaningful, tested on small files and on a file of 100,000 orders. It ties together the posts in this cluster and adds the pieces they refer to: the record-level comparison for big files and the schema compatibility check.

Three questions, not one

"Are these two XML files different?" hides three questions, and each calls for a different tool.

Are they the same document? The answer should ignore everything the format treats as insignificant: attribute order, quote style, whitespace between elements, the XML declaration. The tool is canonicalization.

What changed? The answer should list changes in terms the reader understands: this record was added, that value changed. The tools are normalized text diffs for small files, keyed record comparison for big ones and tree diffs for the cases that need them.

Can a reader of the old format read the new one? This is about schemas, not documents. The tool is validation of a corpus of old documents against the new schema, and of new documents against the old.

Treating these as separate questions avoids the most common waste: running a text diff to answer the first or the third.

Rung 1: are they the same document

A person typing XML, a Python library and a template engine can write one order three different ways. The three files in the test differed in the declaration, attribute order, quote style, spaces inside the start tag and the whitespace between elements. Their checksums:

Text
7fc43ff0bd86e0460a4bcdfea92b9de2  a1.xml
b397c1adc4b011cc55fd592b2130d94f  a2.xml
31bf2ecb8bea4246a2b68ad7a7e1691b  a3.xml

Three sums for one order. Canonical XML (C14N) is the W3C algorithm that writes any document in one agreed form: attributes sorted, double quotes, no declaration, empty elements written out, line endings normalized. xmllint --c14n applies it, and it fixed all of the differences except one, whitespace between elements, which it keeps because XML cannot know whether that whitespace matters. For data files you decide that it does not, and tell the parser to drop blank text:

Python
parser = etree.XMLParser(remove_blank_text=True)
etree.tostring(etree.parse(path, parser), method="c14n")

After that all three files hashed to the same SHA-256 prefix, fdbd5b13d2bcc604. That is a content fingerprint: parse, drop blank text, canonicalize, hash. It does not sort elements, does not know that 19.9 and 19.90 are equal and does not unify namespace prefixes (canonical XML and why your diffs need it before comparing has the details and the limits).

Rung 2: what changed, in a small file

For files you can read, a normalized text diff is the quickest answer. Canonicalize both sides, pretty-print them so each element has its own line, and run diff:

Shell
diff <(xmllint --c14n old.xml | xmllint --format -) \
     <(xmllint --c14n new.xml | xmllint --format -)

On two order files that differed in attribute order, one total and one added order, the output named exactly those: the total changed from 10.00 to 12.50 and an order with id 3 appeared. If element order is not meaningful, sort first. An XSLT stylesheet with one xsl:sort and data-type="number" ordered ids 10, 2 and 3 as 2, 3, 10, where the default text sort gave 10, 2, 3; a non-numeric key sorted first, so bad data shows up at the top (sorting XML elements for stable output and clean diffs).

I also tried xmldiff, which computes tree edit scripts. On repetitive records it paired the wrong elements and printed text updates against shifted positions, even with --unique-attributes id and even after sorting. It is the right tool for documents with distinctive structure, and the wrong one for lists of near-identical records, where a keyed comparison is clearer (diffing XML ignoring attribute order and layout noise).

Rung 3: what changed, in a big file

A normalized text diff of a 33 MB file is not readable, and loading two trees to compare them is expensive. For files made of records with keys, compare the records. Stream each file, canonicalize each record, hash it and keep a dictionary from key to hash. Then compare the dictionaries:

keyed.pyPython
def digest(path):
    out = {}
    for _, el in etree.iterparse(path, events=("end",), tag="order"):
        out[el.get("id")] = hashlib.sha256(etree.tostring(el, method="c14n", with_tail=False)).hexdigest()[:12]
        el.clear()
        while el.getprevious() is not None:
            del el.getparent()[0]
    return out

The test pair was the 100,000-order file and a copy with four edits: one customer name changed, one order removed, one order added, and the attributes of order 7 reordered (a change of form, not of meaning). The plain diff of the two files produced 24 lines of output that a person would have to interpret. The keyed comparison printed:

Text
old=100000 new=100000 added=['100001'] removed=['500'] changed=['1'] (counts 1/1/1)
time=1.8s peak_rss=67MB

One record added, one removed, one changed. The reordered attributes on order 7 were not reported, because the canonical form ignores attribute order, which is the behavior you want. The comparison took 1.8 seconds for both files and 67 MB, most of it the two dictionaries of 100,000 hashes; for files too large for that, write the key and hash to two sorted files and compare them with comm or join. The hash is a fingerprint of each record's canonical form, so it is only as good as the canonicalization: if records contain whitespace that varies, remove blank text before hashing, and if a record contains child elements whose order varies, sort them first.

The same pattern answers a question that shows up in feed processing: which records are new since yesterday? That is the "added" set, and it is the basis for incremental loads (incremental XML feed imports from the last run).

Rung 4: Git and XML

Git works on lines, and XML written without line breaks has one. A generated catalog.xml with eight items on a single line made a one-number change appear as two 600-character lines, with only the beginning visible in the diff. Two ways to fix the display and the history:

A textconv driver runs a command on both versions for display only. With *.xml diff=xml in .gitattributes and git config diff.xml.textconv "xmllint --format", the same change appeared as a nine-line hunk with the item named and one - and one + line. Nothing in the repository changed, and it applies to git diff, git log -p and git show, not to blame, merge or patches.

Storing the file formatted fixes everything: diffs of one line, blame that names the right commit and merges that conflict only when two people touch the same element. It needs one reformat commit and a rule that whatever writes the file writes it formatted, which a CI check enforces: xmllint --format file | diff -q - file fails when the file differs from its formatted form. A pre-commit hook can reformat staged XML automatically. Use git blame --ignore-revs-file to hide the reformat commit from blame (pretty printing XML for cleaner Git history and reviews).

Neither applies to mixed-content documents, where indentation is part of the text, or to signed files, where any change to the bytes breaks the signature.

Rung 5: merging

Two branches that each append a dependency to a pom.xml conflict, because Git sees one line inserted at the same spot on each side. The built-in union merge driver, enabled with pom.xml merge=union in .gitattributes, keeps both lines; the merge succeeded and xmllint --noout confirmed the result was well-formed. The driver reads lines, not XML, so it fails in four ways: it keeps duplicates, it keeps both sides of a conflicting value (two versions of the same dependency), it interleaves lines of multi-line entries, and it picks an order. Use it for one-entry-per-line lists where a build check catches duplicates and clashes, and never for files where values change.

For anything else, write a keyed merge driver: load the base, ours and theirs, index each by the key, apply the changes theirs made that ours did not, and report a conflict when both changed the same key to different values. It is a few dozen lines with lxml, registered with merge.<name>.driver (merging XML config files in Git without manual conflicts). After any XML merge run xmllint --noout, because a merge Git calls clean can produce a file that is not well-formed.

Rung 6: watching a feed change shape

Feeds break silently. A provider renamed price to cost, added a currency attribute and dropped a sale element, and a loader keyed on item/price loaded nulls without an error. A shape snapshot catches this: stream the file, count every element path and attribute path, and compare with the last accepted delivery:

Text
added   /feed/item/badge 1
added   /feed/item/cost 2
added   /feed/item/cost/@currency 2
removed /feed/item/price 2
removed /feed/item/sale 1

Five lines were the complete story. Removals deserve a hard stop, because they are what produces silent nulls; additions can pass with a warning for a loader that reads only the fields it names. Add ratio checks, since an element present in 98 percent of records last week and 4 percent today is a partial change. Run the check as the first step of the load, store each accepted snapshot, and compare with the last accepted one so slow drift cannot slip through (detecting structure changes in an XML feed over time).

Rung 7: versioning the format

The last question is about compatibility. Two documents, one written for each of two versions of a format, and three schemas: the first strict with a fixed sequence, the second adding an optional currency element, the third making it required. Validating each document against each schema with xmllint:

Text
document \\ schema                  s1(strict v1) s2(optional currency) s3(required currency)
d1.xml (v1 document)                valid    valid    INVALID
d2.xml (v2 document)                INVALID  valid    valid

Read it as a compatibility matrix. The optional addition (s2) accepts documents of both versions: old documents still validate (backward compatible) and new ones validate against the new schema. The old strict schema rejects the new document (not forward compatible, so an old reader that validates will refuse the new data). Making the new element required (s3) breaks every old document. That is the whole theory of compatible change in one table: additions that are optional are safe for writers, ignoring unknown elements is what makes them safe for readers, and a required addition is a break.

Turn it into a test. Keep a corpus of real documents from every released version of the format, and in CI validate all of them against the new schema. A document that used to be valid and no longer is represents a breaking change you introduced, whatever the commit message says. Add the reverse check where it matters: new documents validated against the old schema tell you whether old validating readers will reject them, and the answer decides whether you need an extension point (designing XML documents that age well from day one covers xs:any, version markers and namespaces for breaks).

When prefixes differ: a tree signature

Canonical XML sorts attributes and normalizes quoting, and it does not change namespace prefixes, because the prefix is part of how the document is written. Four ways to write the same order, three of them in the same namespace:

Text
prefix o      c14n=915e160551  tree-signature=21c00bcb98
prefix ord    c14n=57e0a4b799  tree-signature=21c00bcb98
default ns    c14n=51322b91c3  tree-signature=21c00bcb98
no namespace  c14n=61a5f6e74f  tree-signature=f57c6d9c89

The first three have different C14N hashes and are the same document in every sense that matters: same namespace URI, same names, same attribute, same text. The fourth is genuinely different, since its elements are in no namespace. A comparison that should treat the first three as equal must ignore prefixes, and the way to do that is to compare parsed names in Clark notation, {namespace}local, which is how lxml represents tags once parsed. A short function walks the tree and writes one line per element with its Clark name, its sorted attributes and its stripped text, and the hash of that text is the signature:

Python
def signature(x):
    out = []
    def walk(el, depth=0):
        attrs = ",".join(f"{k}={v}" for k, v in sorted(el.attrib.items()))
        out.append(f"{'  '*depth}{el.tag}[{attrs}]{(el.text or '').strip()}")
        for ch in el: walk(ch, depth + 1)
    walk(etree.fromstring(x))
    return "\n".join(out)

The three same-namespace variants hash to 21c00bcb98 and the no-namespace one to f57c6d9c89. The printed signature of the first shows what is being compared: {urn:example:orders:1}order[id=7] and, indented, the customer element with its text. This is also the right basis for diffing two documents that come from different producers that chose different prefixes, a frequent case with partner feeds. Two caveats. It ignores comments, processing instructions and tail text, so it is for data, not documents. And it does not sort children: add a sort by tag and key if order is not meaningful.

Three CI jobs that cover it

The techniques become routine when they run on every change. Three jobs, each short, cover most of the cluster.

YAML
xml-checks:
  steps:
    - name: well-formed and formatted
      run: |
        for f in $(git ls-files '*.xml'); do
          xmllint --noout "$f"
          xmllint --format "$f" | diff -q - "$f" > /dev/null || { echo "$f is not formatted"; exit 1; }
        done
    - name: feed shape against the last accepted snapshot
      run: python tools/shape.py snapshots/last.txt feeds/today.xml
    - name: old documents still valid
      run: |
        for d in corpus/v*/*.xml; do
          xmllint --noout --schema schema/current.xsd "$d" || exit 1
        done

The first job keeps files well-formed and stored in one canonical layout, so Git diffs stay small and merges stay sane. The second compares a feed's path snapshot with the last accepted one and fails on removals, the check that prevents silent nulls. The third validates the corpus of old documents against the current schema, and fails if a change made any of them invalid. Together they take seconds, need only xmllint and a small script, and turn the breakages described in this guide into failed builds.

Two practical points. Store the snapshot and the corpus in the repository, reviewed like code; updating the snapshot is a deliberate act with a commit message that says why the feed changed. And make the failure messages say what to do: "removed /feed/item/price (2 occurrences); if intended, update snapshots/last.txt and notify consumers". A red check that explains itself gets fixed; one that does not gets disabled.

Comparing across tools and formats

Sometimes the two things to compare are not both XML: a feed and the table it was loaded into, a config and its JSON export, a document and the result of a transformation. The principle holds: reduce both sides to the same neutral form and compare that. For XML against a table, extract the fields into rows and compare keyed hashes of the rows. For XML against JSON, convert both to data structures in one language and compare them, accepting that attribute-versus-element choices must be mapped. For an XSLT result, compare the output's canonical form with an expected file kept in the repository. These are the same tests as above with a different second input, and they share the habit of reporting counts as well as differences (moving from XML to JSON gracefully shows a mapping and the parity test that goes with it).

When the comparison is part of a migration (old pipeline against new), run both on the same inputs for a period and diff the outputs daily. A keyed comparison that reports "0 added, 0 removed, 0 changed" for thirty days is the evidence to switch over, and the first nonzero count is a bug in one of the two.

Choosing the rung

QuestionUseCost
Is it the same documentCanonicalize, drop blank text, hashMilliseconds
What changed in a small fileCanonicalize, format, sort, diffSeconds
What changed in a big fileKeyed record hashes1.8 s for 33 MB
Review in Gittextconv driver, or store formattedConfig line, or one commit
Merge list-like filesunion driver plus a build check, or a keyed driverCare
Did the feed change shapePath snapshot with countsOne streaming pass
Is the new format compatibleCorpus validated against old and new schemasOne CI job

Most teams need the first four. The last three become important when a file has several writers, when partners depend on a feed or when the format has more than one consumer.

Reading a diff you did not expect

When a comparison reports differences you cannot explain, work down a short list before concluding that the data changed. First check the encoding and the declaration: a file saved as UTF-16 or with a byte order mark differs in bytes and canonicalizes the same, so a byte-level difference with an empty canonical difference is a harmless encoding change. Then check line endings, which canonicalization normalizes, and whitespace, which it does not: a difference that disappears when blank text is dropped is formatting. Then check attribute order and quote style, which canonical form removes, and namespace prefixes, which it does not.

If the difference survives all of that, it is content, and the next question is whether it is meaningful content. Producers add timestamps, generated identifiers and sequence numbers that change on every export. A volatile field in every record makes every record "changed". Find them by looking at which elements differ most often across a diff, and exclude them from the fingerprint: remove the element before hashing, or hash only the fields that define identity and state. The same approach appears in JSON testing, where volatile fields are masked before snapshots are compared (comparing JSON ignoring volatile fields covers the JSON side).

Finally, check order. Two exports that contain the same records in a different sequence differ in every positional comparison and in none of the keyed ones. If a keyed comparison reports no difference and a text diff reports thousands of lines, the text diff is seeing reordering, and the keyed result is the truthful one for data where order is not meaningful. Keep both numbers in the report so that a reader can see which kind of change occurred.

Retention and provenance

Versioning is more than diffing. For XML that others depend on, keep a history that answers questions later: which version of the schema was in force on a date, which documents were written under it, and which transformation produced a derived file. Store each schema version as an immutable file with its version in the name and in the namespace or a version attribute of the documents. Keep the corpus organized by version. Record, for generated files, the hash of the inputs and the version of the generator, in a comment or a sidecar manifest, so that an unexpected difference can be traced to its cause.

When you retire a version, do it with the same care as the migration: announce a date, count the remaining writers and readers, keep the schema and a validator available after the end of support so that archived documents can still be checked, and stop only when the counts are zero. Format versioning fails less often from technical mistakes than from nobody knowing who still uses the old one.

A small comparison tool you can keep

Putting the rungs behind one command makes them used. A script that takes two paths and a mode is enough. In same mode it canonicalizes both files with blank text removed and prints whether the hashes match. In diff mode it prints the normalized text diff. In keyed mode, given the record tag and the key attribute, it prints the added, removed and changed counts with the first few keys of each. In shape mode it prints the path differences between the two files. In schema mode, given an XSD, it validates both and prints which pass. Five modes, a hundred lines, and every function in it is one of the snippets in this guide.

Design the output for people and for machines. The default is a short human report with counts first. A --json flag prints the same result as a JSON object for CI, and the exit status is nonzero when there is a difference the caller asked about. Accept - for standard input so it works in pipelines and in git difftool, and have a --ignore option that takes an XPath of elements to drop before comparing, for the volatile fields you find. Test the tool with the awkward files: one with a different encoding, one with different prefixes, one with attributes reordered, one with a changed value and one truncated mid-element. A truncated file must produce a clear error and not a report of a thousand deletions.

Teams that keep such a tool in the repository end up using it constantly: in code review, in incident analysis, in migrations. The cost is a day to write, and the benefit is that "did this change?" becomes a question with a quick, trusted answer.

Habits

Normalize at the source if you can. A producer that writes formatted, sorted XML makes every downstream comparison trivial.

Compare the right thing. Meaning for review, bytes for integrity checks. A signature or a checksum over exact bytes cannot use a canonical diff.

Keep a corpus. Real documents from every version, including the odd ones, are the best regression suite for comparison scripts and schemas alike.

Report counts, not only differences. "1 added, 1 removed, 1 changed of 100,000" is a result; a list of differences with no totals is a puzzle.

**Run xmllint --noout after every tool that writes XML.** Merges, scripts and editors all produce malformed files occasionally.

The posts in this cluster take the rungs one at a time: canonical XML, diffing while ignoring attribute order, pretty printing for Git, detecting structure changes, merging config files and sorting elements. For the format design behind the compatibility test, read designing XML documents that age well from day one, and for comparing two documents interactively comparing two XML documents to find differences.

References: Canonical XML 1.0, the Git gitattributes documentation, the xmldiff documentation and the XML Schema structures specification. Format and compare snippets in the XML formatter and the text diff tool.

When this approach to comparing XML does not apply

The file is a document, not data. For prose with inline markup, canonicalizing and sorting change meaning. Compare extracted text with a word diff, and accept that formatting differences will show.

Records have no key. Any matching is a guess. Get a key from the producer, or derive one from stable fields.

You need to prove the bytes are unchanged. Use a checksum of the original file. Canonical comparison answers a different question.

The documents are signed. Reformatting, sorting or canonicalizing a copy is fine for review, and doing it to the original breaks the signature.

Your files are huge and unkeyed. A multi-gigabyte file without record keys needs a different design: split at record boundaries, hash each part and compare part by part, or load both into a database and compare there.

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.