Converting YAML to XML With Python and Valid Names
Convert YAML to XML with xmltodict, using @ keys for attributes, repeated elements for lists, and checks for invalid names and the single-root rule.

XML and YAML describe trees, which makes converting between them look mechanical. It is mechanical in one direction only for simple data. YAML has mappings and lists and scalars; XML has elements, attributes, text, namespaces and a rule that names must follow a grammar. Any conversion invents a convention for the parts YAML lacks, and the convention has to be written down. This post converts YAML to XML in Python, shows the three outputs that go wrong, and ends with the check that catches them. It belongs to the cluster on converting YAML to and from JSON, CSV, XML and Excel; the XML side of the same problem is covered in converting XML to JSON and the attribute problem and its siblings in the XML cluster.
The mapping and the result
xmltodict is best known for parsing XML into dictionaries, and unparse goes the other way. A dictionary from YAML with an attribute marker, a list of mappings and a list of scalars:
shop:
'@id': s1
name: Corner Shop
item:
- {sku: A1, price: 2.5}
- {sku: B2, price: 4}
tags: [x, y]import xmltodict
import yaml
doc = yaml.safe_load(open("shop.yaml"))
print(xmltodict.unparse(doc, pretty=True))<?xml version="1.0" encoding="utf-8"?>
<shop id="s1">
<name>Corner Shop</name>
<item>
<sku>A1</sku>
<price>2.5</price>
</item>
<item>
<sku>B2</sku>
<price>4</price>
</item>
<tags>x</tags>
<tags>y</tags>
</shop>The conventions in force:
A key starting with
@becomes an attribute of the enclosing element.'@id': s1gave<shop id="s1">. The key is quoted in YAML because a bare@cannot start a plain scalar.A list becomes repeated elements with the key as the tag. Two
itemmappings gave two<item>elements, and the listtags: [x, y]gave two<tags>elements, one per value. There is no wrapper, so a reader cannot tell a list with one entry from a single value.A key
#textcarries the text of an element that also has attributes.The top-level mapping must have exactly one key, which becomes the root element.
Decide whether you want a wrapper, <tags><tag>x</tag><tag>y</tag></tags>. The library will not insert one; write the YAML that way, or transform the dictionary before unparse. XML schemas usually expect the wrapped form because it lets an empty list exist as an empty <tags/>.
Where it goes wrong
Names that are not XML names. An XML element name must start with a letter or an underscore and cannot contain spaces. YAML keys have no such limit. Trying three bad keys:
<root>
<1st>1</1st>
</root>
ValueError Invalid element name: whitespace not allowedThe key has space was rejected with a clear message, which is the good case. The key 1st was not rejected: xmltodict wrote <1st>1</1st>, which looks fine and is not well-formed XML. Parsing it with lxml:
XMLSyntaxError StartTag: invalid element name, line 1, column 8 (<string>, line 1)So the writer's silence is not a promise. A consumer using a real parser will refuse the file, and you will find out when it does. Sanitize keys before converting: replace spaces and punctuation with underscores and prefix a name that starts with a digit (_1st). Keep a record of the mapping if the XML must round trip.
More than one root. A YAML file whose top level has two keys is a perfectly good mapping and not a perfectly good XML document:
ValueError Document must have exactly one root.Wrap it: {"root": doc}. Choose the root name deliberately, because it ends up in the consumer's schema.
Nested lists. A list inside a list has no natural XML form. In our test, {"a": {"b": [1, 2]}} produced <a><b>1</b><b>2</b></a>, which is fine for one level. A list of lists would flatten ambiguously, so avoid them or give the inner lists a name.
Types are text in XML
Everything in XML is text. A YAML integer, a boolean, a null and a date all become the character data between tags, and nothing records that 4 was a number. We checked what xmltodict does with each:
<r><x></x><y></y><b>true</b><n>4</n></r>That came from {'x': None, 'y': '', 'b': True, 'n': 4}. A None and an empty string both became an empty element, so the XML cannot say which was meant. The boolean True was written as true, the lexical form XSD expects for xs:boolean, which is a courtesy of this library and not a rule of the format; ElementTree would write str(v), which is True. Check the writer you use. Dates need care in either case: a datetime.date object from PyYAML becomes 2026-10-27 through str, but a datetime includes a space where an XSD xs:dateTime wants a T, so call isoformat() first. Floats keep Python's repr, and NaN has no XSD form, so remove or map it. If the consumer has an XSD, its types decide how the text is read, and your job is to write each value in the schema's lexical form.
Validate the output with a real parser
The last step of any conversion should be the one the consumer performs. For XML, that is parsing and, if there is a schema, validating:
from lxml import etree
etree.fromstring(xml_text.encode("utf-8"))An exception here is a bug in your conversion, found before delivery. With an XSD, etree.XMLSchema(etree.parse("schema.xsd")).assertValid(tree) checks structure and types. Validating XML in Python with lxml and XSD goes through those steps in detail. Wire the parse into the same script that writes the file, so the output cannot be saved invalid.
Order, attributes and mixed content
Two XML features have no YAML home. Attributes you can encode with the @ convention, at the price of a quoting rule every YAML author must remember. Mixed content, text with child elements interleaved as in <p>Hello <b>there</b></p>, cannot be represented as a mapping without losing the order of the pieces. If your XML is document-like, such as DocBook or JATS, do not start from YAML.
Order is a smaller worry. PyYAML keeps mapping order as written, and xmltodict writes children in dictionary order, so the XML follows the YAML. But two mapping keys with the same name cannot both exist, so an XML sequence like <a/><b/><a/> cannot be written as a mapping at all. Use a list of single-key mappings if the interleaving matters.
Doing it without a library
For a small, fixed shape, the standard library is enough and gives you full control:
import xml.etree.ElementTree as ET
root = ET.Element("shop", id="s1")
ET.SubElement(root, "name").text = "Corner Shop"
for item in doc["shop"]["item"]:
e = ET.SubElement(root, "item")
for k, v in item.items():
ET.SubElement(e, k).text = str(v)
print(ET.tostring(root, encoding="unicode"))With a value of A & B <c>, ElementTree wrote <name>A & B <c></name>, escaping & and < in text, which a string template would not. Escaping is the other reason not to build XML with f-strings: a value containing & makes the output invalid, silently.
When YAML does not map cleanly to XML
The target has a schema with attributes, namespaces and ordering. Hand-written XML conventions in YAML will drift from the XSD. Generate from the schema's model, or write the XML with a template engine built for it.
The data has mixed content. Documents with inline markup are XML's home. Keep them as XML.
You need round trips. XML to YAML and back rarely returns the same XML, since attributes, ordering and comments get reshaped. Treat one format as the source of truth.
Names come from users. Arbitrary keys need sanitizing, and sanitizing collides (a b and a_b). Use a fixed element name with a name attribute instead: <entry name="a b">.
The library's behavior is documented in the xmltodict project page, the name rules in the W3C XML specification and the standard-library API in the ElementTree reference. To check a result by eye, paste it into the XML formatter.


