Skip to content
datahacks.dev
XML

Migrating a Legacy XML Interface to JSON in Safe Steps

A step-by-step plan for moving an XML service to JSON without breaking consumers: freeze, map, run both, measure, announce a date and retire.

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

An XML interface that has served for ten years has consumers you can name and consumers you cannot. Replacing it with JSON is a reasonable goal, and a risky one, because the work is not in producing JSON. It is in changing a contract that other people depend on without them noticing, until they have to. This post lays out the order of operations that has the fewest surprises, with one tested piece, the parity check that keeps both formats honest.

The plan in seven steps

  1. Freeze the XML contract. Write down what the interface returns today, with the schema if one exists and samples if not. No format changes during the migration, or you are chasing a moving target.

  2. Collect a corpus. Gather real responses covering the shapes in production: the smallest, the largest, optional parts present and absent, empty lists, special characters, non-ASCII text. A week of sampled logs, anonymized, is a good source.

  3. Define the JSON mapping explicitly. Which names, which types, which elements are arrays, what an empty element becomes. Do not use a generic converter's guesses (moving from XML to JSON gracefully lists the rules to decide).

  4. Serve both from one model. Build the response from an internal object and render it as XML or JSON, rather than converting one output into the other. Negotiate by Accept header or a path suffix.

  5. Test parity on the corpus. For each case, check that the two renderings carry the same information.

  6. Run both, and measure. Log which clients call which format. Announce the plan.

  7. Retire XML on a date you published, after the numbers say the stragglers are known and warned.

Steps 1 to 3 are design, 4 and 5 are code, and 6 and 7 are people. Most failed migrations skipped 2 or 6.

One model, two renderers

The key design choice is step 4: never derive one format from the other. If JSON is produced by converting the XML output, every XML quirk leaks into the JSON, and every JSON change needs an XML change. If both come from the same object, each renderer is small and testable:

dual.pyPython
import json
from dataclasses import dataclass, asdict
from lxml import etree

@dataclass
class Line:
    sku: str
    qty: int

@dataclass
class Order:
    id: int
    customer: str
    lines: list
    note: str | None = None

def to_json(o: Order) -> str:
    return json.dumps(asdict(o), separators=(",", ":"))

def to_xml(o: Order) -> bytes:
    r = etree.Element("order", id=str(o.id))
    etree.SubElement(r, "customer").text = o.customer
    ls = etree.SubElement(r, "lines")
    for l in o.lines:
        etree.SubElement(ls, "line", sku=l.sku, qty=str(l.qty))
    if o.note is not None:
        etree.SubElement(r, "note").text = o.note
    return etree.tostring(r)

The output for an order with no note:

Text
{"id":2,"customer":"Bo","lines":[{"sku":"C","qty":1}],"note":null}
<order id="2"><customer>Bo</customer><lines><line sku="C" qty="1"/></lines></order>

The same order, two documents. Notice the choices embedded in the renderers. The absent note is null in JSON and an absent element in XML. Quantities are numbers in JSON and text attributes in XML. These are decisions made once, in code, and visible in a diff. Compare the XML half with the details in keeping both XML and JSON output from one source, which goes deeper into content negotiation and testing.

The parity test

Having two renderers raises the question: do they say the same thing? A test answers it by reading the XML output back into a plain dictionary and comparing with the JSON output parsed into one:

Python
def xml_to_model(b: bytes) -> dict:
    r = etree.fromstring(b)
    return {"id": int(r.get("id")), "customer": r.findtext("customer"),
            "lines": [{"sku": l.get("sku"), "qty": int(l.get("qty"))} for l in r.find("lines")],
            "note": r.findtext("note")}

cases = [
    Order(1, "Ana", [Line("A", 2), Line("B", 1)], "gift"),
    Order(2, "Bo", [Line("C", 1)]),
    Order(3, "Cy", []),
    Order(4, "Di & <Co>", [Line("D", 5)], ""),
]
for o in cases:
    same = json.loads(to_json(o)) == xml_to_model(to_xml(o))
    print(o.id, "parity", same)
Text
1 parity True
2 parity True
3 parity True
4 parity True

The four cases are chosen on purpose: a normal order, an order with no note (absent), an order with an empty list of lines, and an order with special characters and an empty-string note (empty, which must stay different from absent). All four agree, and they agree because the reader distinguishes the cases: findtext returns None for an absent element and '' for an empty one. If the XML renderer wrote <note/> for a missing note, case 2 would read back as the empty string and the test would fail, which is its job. Run the same test over your real corpus, and treat a failure as a design question, not a nuisance: it points at a case where the two formats say different things.

The cases that bite

A list with one item. The generic XML-to-JSON habit of turning a single child into a scalar breaks consumers that loop. The mapping must state which elements are arrays.

Absent, empty and null. XML has three states and JSON two. Decide how each maps and write it down.

Attribute or element. In JSON they are both keys. If an element has an id attribute and an id child, one wins, and you lose data. Check the corpus for such names.

Numbers as text. qty="2" in XML is text, and in JSON it should be a number only if the schema says so. A zip code 01234 or an account number must stay a string, or the leading zero vanishes.

Dates and booleans. XML sends 2026-03-14 and true as text, and consumers parse them. Keep the same lexical forms in JSON unless you announce a change.

Order. XML element order is part of the document and JSON object key order is not guaranteed by the standard. If order matters, use an array.

People: announce, measure, retire

The technical part is the smaller half. Add a deprecation notice where consumers will see it: a response header (Deprecation and Sunset are defined for exactly this, in RFC 9745 and RFC 8594), a field in the XML, an email to registered clients. Publish the end date and a migration guide with side-by-side examples taken from your corpus.

Measure usage by client and by day. A dashboard that shows the share of traffic still on XML, by API key, tells you who to call. Expect a long tail: a few clients will migrate in the last week, and one will not know the email went to a mailbox nobody reads. When the date arrives, do not turn it off silently. Return a clear error that explains what happened and where to find the new format for a defined period, then remove it.

Keep the XML endpoint's tests until the day it is deleted. A regression in the old format during the migration is the quickest way to lose consumers' trust.

The cluster hub is choosing XML over the alternatives, or not. The mapping decisions are in moving from XML to JSON gracefully, and the serving side in keeping both XML and JSON output from one source. Header definitions: RFC 8594, the Sunset header, and the JSON grammar in RFC 8259. Convert a sample in the XML to JSON tool.

When migrating the interface is not worth it

The consumers are few and stable. If three internal systems read the XML and none is changing, the migration costs more than it returns.

The format is mandated. If a regulator or a partner standard requires XML, you cannot migrate. Add JSON beside it only if someone asks.

The pain is elsewhere. If slow responses or unclear errors are the complaint, a new format will not fix them. Find the real cause.

You cannot run both. If a flag day is the only option, the risk is high enough that you should reconsider the plan.

Also inJSON
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.