Skip to content
datahacks.dev
XML

Keeping Both XML and JSON Output From One Source Model

Build one internal model and two small renderers, negotiate the format with the Accept header, and test that both carry the same data on awkward cases.

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

Serving both XML and JSON sounds like double the work. It is double the rendering and the same amount of everything else, provided you build the pipeline in the right order. The wrong order, converting one output into the other, is how the two formats drift and how a bug fixed in one reappears in the other a month later. This post shows the structure that avoids it, with the code, and the one thing you must test.

The wrong way and the right way

The tempting shortcut is to build the XML your legacy consumers know and generate JSON by running it through a converter. It is quick for the first release. It fails slowly. Each XML quirk becomes a JSON quirk: attribute prefixes like @id, one-element lists that turn into scalars, an #text key. Every change to the XML response changes the JSON by accident, and the JSON consumers are the ones who find out.

The reverse has the same problem. Generating XML from a JSON structure gives you element names from whatever keys the JSON happens to have, with no way to say "this is an attribute".

The right way is a model in the middle. The application builds an object that represents the order in terms of the domain. Two functions turn the object into bytes. Neither knows about the other:

Text
database -> Order (dataclass) -> to_json()  -> application/json
                              -> to_xml()   -> application/xml

Each renderer owns its format's rules. The JSON renderer decides that an absent note is null. The XML renderer decides that the same absence is a missing element. When the product asks for a new field, you add it to the model and to each renderer, and the diff shows both changes together.

The model and the renderers

The model is a plain data class. It uses Python types, so there is no XML or JSON in it:

dual.pyPython
from dataclasses import dataclass, asdict
import json
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)

For an order with no note and one line, the outputs are:

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>

lines is always an array in the JSON and always a wrapper in the XML, even for one line or none, because the renderers say so. That removes the single-item problem at its source (modeling lists and optional data in XML explains it). Special characters are escaped by the libraries (&amp; and &lt; in XML, backslashes in JSON), so you do not write escaping code.

If you also need a schema, derive the XSD and the JSON Schema from the same model definition, or write both from one description and test each renderer's output against its schema. The principle holds: one source of truth, many renderings.

Choosing the format per request

HTTP has a mechanism for this: the Accept header. A client asks for what it wants, and the server answers or says it cannot. A minimal rule set:

  • Accept: application/json returns JSON.

  • Accept: application/xml or text/xml returns XML.

  • No header, or */*, returns your default. Choose the default deliberately. For a migration from XML to JSON, the default is the old one until you announce otherwise, since clients that send nothing are probably the oldest.

  • An unsupported type returns 406 Not Acceptable.

Return a Vary: Accept header, so caches do not serve the JSON bytes to an XML client. Set Content-Type correctly, with a charset for XML if you are not putting the declaration in the document. A path suffix (/orders/2.json, /orders/2.xml) is a friendly addition for people using a browser or curl, and it should map to the same renderers.

The parity test

Two renderers can disagree without anyone noticing, so test them against each other. Read each rendering back into one plain dictionary and compare:

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:
    print(o.id, "parity", json.loads(to_json(o)) == xml_to_model(to_xml(o)))
Text
1 parity True
2 parity True
3 parity True
4 parity True

The cases are the point. Case 2 has no note, so the XML has no element and the JSON has null. Case 3 has no lines, so the XML has an empty wrapper and the JSON an empty array. Case 4 has markup characters in a name and an empty note, which must read back as the empty string and not as absent. findtext makes that distinction: it returns None for a missing element and an empty string for an empty one. If a renderer ever merged the two states, one of these cases would fail.

Grow the test with your data. Add a case for every bug that reached production and every field you add. Run it in CI so a change to one renderer without the other fails the build. For an API with a published schema, also validate each rendering against its schema in the same test.

Keeping them equivalent over time

A few habits stop drift.

Add fields to both renderers in one commit. A review checklist item is enough: "does this change touch both?".

Do not special-case one format in the model. If you find yourself adding a field only for XML, ask whether it belongs in a renderer instead.

Log the format. Counting requests by format tells you when one is unused. When the XML share reaches zero for a long period, you can retire it (migrating a legacy XML interface to JSON covers announcing and removing).

Version both together. A breaking change to the model is a new version of both formats.

The cluster hub is choosing XML over the alternatives, or not. The mapping rules are in moving from XML to JSON gracefully, and the JSON side of conversions in converting JSON to XML with sensible element names. Content negotiation is defined in RFC 9110 section 12, and the Vary header in section 12.5.5. Compare two renderings of one record in the XML to JSON tool.

When two renderers are more than you need

One format has no consumers. If nobody calls the XML, delete it instead of maintaining it.

The formats have different shapes by design. If the XML must follow a standard vocabulary and the JSON is a simpler custom shape, the two cannot be rendered from one mapping, and a separate adapter per format is more honest.

The data is generated by a framework that does it for you. Many frameworks serialize a model to either format from the same annotations. Use that, and test the output rather than the code.

It is an internal one-off. A script that writes a file once does not need content negotiation.

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.