Skip to content
datahacks.dev
Excel

Converting Excel to XML With a Python Script Safely

Convert an Excel sheet to XML in Python: valid element names, escaping, elements versus attributes, encoding, large files and validation against a schema.

7 min read
Summarize in AI
Flat illustration of a spreadsheet grid with a selected cell and a formula bar, with the XML logo.

XML is old and still everywhere that data crosses an organization boundary: supplier feeds, regulatory filings, accounting imports, product catalogs. Someone keeps the data in a workbook, and the partner wants XML. The conversion is mechanical, with sharp edges at the names, the escaping, the characters and the structure that the receiving system expects. This post builds the conversion with the standard library and lxml, shows how to deal with each edge and how to validate the output against a schema. It is a spoke of converting Excel to CSV, JSON, XML and YAML.

Decide the shape first

There is no single XML form of a table. Agree one with the receiver before writing code. The usual shape:

XML
<?xml version="1.0" encoding="UTF-8"?>
<orders>
  <order id="0007">
    <date>2025-03-14</date>
    <customer>Acme &amp; Sons</customer>
    <qty>2</qty>
  </order>
  <order id="0012">
    <date>2025-03-15</date>
    <customer>Globex</customer>
  </order>
</orders>

Decisions inside it: the name of the root and of the row element; which columns become attributes (identifiers, short flags) and which become child elements (everything else); how to represent an empty cell (omit the element, write an empty one, or use xsi:nil="true"); the format of dates and numbers; and whether a namespace applies. Attributes cannot repeat and have no structure, so use elements for anything that might grow. Missing versus empty is a real distinction in XML: an absent element and an empty element can mean different things to a validator.

If the receiver supplies an XSD or a sample, follow it exactly, including element order, which XSD sequences enforce.

Names

Element and attribute names follow strict rules. A name must start with a letter or underscore, may contain letters, digits, hyphens, underscores and periods, and cannot contain spaces. Names starting with xml (any case) are reserved. A column header such as Order No, 2025 Sales, Unit Price ($) or Café is not a valid name, or not a safe one. Clean headers with a function and check for collisions:

Python
import re

def xml_name(h):
    s = re.sub(r"[^0-9A-Za-z_.-]+", "_", str(h).strip()).strip("_.-")
    if not s or not re.match(r"[A-Za-z_]", s):
        s = "_" + s
    if s.lower().startswith("xml"):
        s = "_" + s
    return s

names = [xml_name(c) for c in df.columns]
assert len(set(names)) == len(names), "duplicate element names"

Accented letters are legal in XML names, but many consumers prefer ASCII; the regular expression above removes them, which can collapse distinct headers, so keep the uniqueness assertion. Keep a mapping of original header to name for the documentation.

Build the document, do not concatenate

Never build XML by gluing strings. A customer called Acme & Sons or O'Brien <test> breaks the document, or worse, injects structure. A library escapes for you:

Python
import xml.etree.ElementTree as ET
import pandas as pd

df = pd.read_excel("orders.xlsx", sheet_name="Orders", dtype={"Order No": str})
df["Order Date"] = pd.to_datetime(df["Order Date"]).dt.strftime("%Y-%m-%d")

root = ET.Element("orders")
for _, r in df.iterrows():
    order = ET.SubElement(root, "order", id=str(r["Order No"]))
    for col, name in (("Order Date", "date"), ("Customer", "customer"), ("Qty", "qty")):
        v = r[col]
        if pd.isna(v):
            continue                       # omit empty elements
        ET.SubElement(order, name).text = str(v)

ET.indent(root)                            # Python 3.9+
ET.ElementTree(root).write("orders.xml", encoding="utf-8", xml_declaration=True)

ElementTree escapes text and attribute values on output. ET.indent makes the file readable. xml_declaration=True writes the declaration line. If you need namespaces, comments, schema validation or pretty-printing control, use lxml, whose API is a superset:

Python
from lxml import etree

root = etree.Element("orders", nsmap={None: "urn:example:orders:1"})

pandas has DataFrame.to_xml, which writes a table to XML in one call (df.to_xml("orders.xml", index=False, root_name="orders", row_name="order", parser="etree")). It is handy for flat tables. It uses lxml by default, and has options for attributes (attr_cols) and elements (elem_cols). For control over structure, build the tree yourself.

Characters and encoding

XML 1.0 forbids most control characters (everything below U+0020 except tab, line feed and carriage return), and the Unicode noncharacters. A cell that contains a stray vertical tab or a null from a bad copy will make the document invalid, and some libraries will write it anyway. Filter values:

Python
ILLEGAL = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f\ud800-\udfff￾￿]")
clean = ILLEGAL.sub("", str(v))

Write UTF-8, declare it, and let the library handle entity references. Do not write a declaration of one encoding and bytes of another; that is the cause of a long family of parse errors (XML encoding declarations and wrong encodings). Leading and trailing whitespace in values is significant in element text, and trimming it is a decision to make: sheets often carry trailing spaces (trimming spaces and non breaking spaces). Line breaks inside cells become line feeds in the text; if they matter, keep them, and be aware that attribute values normalize them to spaces.

Values: write dates in ISO 8601, numbers with a dot as decimal separator and no thousands separators, booleans as true/false (the XSD forms) unless the receiver says otherwise. Do not write Excel's display text.

Large sheets

A DOM tree in memory is fine for tens of thousands of rows. For more, write incrementally. lxml's xmlfile writes elements as you go with constant memory:

Python
from lxml import etree

with etree.xmlfile("orders.xml", encoding="utf-8") as xf:
    xf.write_declaration()
    with xf.element("orders"):
        for row in rows():                        # a generator from openpyxl read-only mode
            el = etree.Element("order", id=row["order_no"])
            etree.SubElement(el, "customer").text = row["customer"]
            xf.write(el)

Feed it rows from an openpyxl read-only iterator (converting a huge Excel file to CSV or Parquet describes the reading side). Each element is written and released.

Validate against a schema

An XSD turns the agreed structure into an automatic check:

Python
from lxml import etree

schema = etree.XMLSchema(etree.parse("orders.xsd"))
doc = etree.parse("orders.xml")
if not schema.validate(doc):
    for err in schema.error_log:
        print(err.line, err.message)
    raise SystemExit("invalid output")

Validation catches wrong element order, a missing required element, a value that does not fit its type (a date in the wrong form, a negative quantity where a positive integer is required). If no XSD exists, write a small one; the exercise forces agreement on types and optionality. Well-formedness (that the document parses) is the minimum, and validity (that it matches the schema) is the real test. An XML file that parses but fails the receiver's schema is the usual cause of a rejected feed (well-formed versus valid XML).

Verify the content

Count: the number of row elements equals the sheet's data rows. Total: parse the XML back, sum qty, and compare with Excel's sum. Check that an identifier with leading zeros kept them, that special characters survived (Acme & Sons, an accented name, a quote), that dates are in the agreed form and that empty cells appear the way the receiver expects. Open the file in a browser or an XML editor: a parse error shows at once. For queries against the result, XPath tests are quick (XPath queries that cover most real needs).

Pitfalls

  • Names built from headers that begin with a digit or contain spaces.

  • String concatenation instead of a library, with unescaped ampersands.

  • Control characters copied from other systems.

  • Mixed encodings between declaration and bytes.

  • Numbers as Excel displayed them, with thousands separators or currency symbols.

  • Floating-point artifacts, 0.30000000000000004; round before writing.

  • Empty versus absent, which validators treat differently.

  • Order of elements, which XSD sequences enforce.

  • Very long text, beyond limits the receiver sets.

When XML is not the right target

The consumer accepts JSON or CSV. Those are simpler, smaller and easier to produce (converting Excel to JSON with Python for clean output).

The structure is deeply nested. A flat sheet flattens badly into nested XML; model the hierarchy first.

No schema exists and nobody can say what the receiver needs. Find out; XML without an agreed structure is just text with angle brackets.

You need to preserve formatting. XML here carries data only. For formatted documents consider other formats.

See the Python documentation for xml.etree.ElementTree, the lxml tutorial and the W3C XML 1.0 specification for the rules on names and characters. To format a snippet, use the XML formatter.

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.