xmltodict
xmltodict is a small module with two functions: parse() turns XML into ordinary Python dicts and lists, and unparse() turns those dicts back into XML. Attributes become keys prefixed with @, text content becomes a key called #text, repeated sibling elements become a list, and everything else nests the way you would expect from JSON. Under the hood it drives the standard library's expat parser, so it is fast and adds no dependencies. The pitch is that you stop writing ElementTree traversal code for XML APIs and SOAP responses and just index into dicts like any other payload.
The fastest way to stop writing ElementTree boilerplate when you are reading a known XML shape, and the streaming callback quietly handles files far bigger than memory. Reach for lxml the moment you need XPath, validation, or byte-exact round-trips, because xmltodict is honest about not doing those.
Use it if
- You are consuming an XML API or config file whose shape you already know and you want dict access instead of ElementTree find() and findall() chains
- You are converting XML to JSON, or bridging an XML upstream into a codebase where everything else is dicts
- You want zero dependencies and no build step: it wraps the standard library's expat parser, so it installs anywhere Python runs
- You need to walk a multi-gigabyte XML dump (a Wikipedia or Discogs export) without loading it into memory, using the item_depth streaming callback
- You need XPath, XSLT, schema validation, or the ability to modify a document in place: none of that exists here, and lxml does all of it
- Your consumers cannot handle a shape that changes with the data: one <item> parses to a dict and two parse to a list, so a feed that usually has several entries breaks the day it has one unless you set force_list everywhere
- You want typed values: every leaf comes out as a string, so numbers, booleans, and timestamps stay text until you write a postprocessor for each field
- You need exact fidelity: the README states plainly that it covers the common 90% of cases and does not preserve attribute order, mixed-content ordering, or multiple top-level comments, so round-tripping a signed or schema-bound document will not give you back the same bytes
- You are parsing XML from sources you do not trust: entity parsing is disabled by default, but defusedxml exists specifically to cover the wider set of XML attacks and is the safer starting point
- Your document has mixed content (text interleaved with child elements): the dict model flattens it, and you lose the ordering between the text runs and the elements around them
Setup reality
pip install xmltodict is instant, pure Python, and pulls in nothing at runtime because it drives the standard library's expat parser. Two rough edges. Type stubs are not bundled, so mypy and pyright users have to install types-xmltodict separately or every call comes back as Any. And 1.0.0 in September 2025 was a breaking release that dropped the old compatibility paths and set the floor at Python 3.9, so a project still pinned to the 0.13 line should read the notes before bumping. Expect one more conversation you did not plan for: scanners flag CVE-2025-9375 against this package, and the maintainer disputes it in the README on the grounds that the underlying behavior lives in Python's own xml.sax.saxutils.XMLGenerator, which does not validate element names either. Disputed or not, someone in your security review will ask about it.
Patterns
Parse a string into nested dictsparse-xml
import xmltodict
doc = xmltodict.parse('''
<order id="42">
<customer>Ada</customer>
<line>widget</line>
<line>gadget</line>
</order>
''')
print(doc['order']['@id']) # '42'
print(doc['order']['customer']) # 'Ada'
print(doc['order']['line']) # ['widget', 'gadget']Attributes get the @ prefix, and repeated siblings collapse into a list. Note that @id is the string '42', not an int; nothing is coerced.
Read an element that has both attributes and textattributes-and-text
doc = xmltodict.parse('<price currency="USD">19.99</price>')
print(doc['price']['@currency']) # 'USD'
print(doc['price']['#text']) # '19.99'
# with no attributes the element is just a string
plain = xmltodict.parse('<price>19.99</price>')
print(plain['price']) # '19.99'This is the shape change that causes the most bugs: adding one attribute upstream turns a string into a dict, and doc['price'] stops being what your code expected.
Guarantee a list even for a single elementforce-list
xml = '<catalog><item>only one</item></catalog>'
doc = xmltodict.parse(xml, force_list=('item',))
print(doc['catalog']['item']) # ['only one']
# or decide per element with a callable
def as_list(path, key, value):
return key in {'item', 'tag'}
doc = xmltodict.parse(xml, force_list=as_list)Set this for every repeatable element on day one. The alternative is an isinstance(value, list) check at every call site, and the bug only shows up in production when a feed happens to return exactly one record.
Convert a dict back to XMLunparse-dict
payload = {
'response': {
'@version': '2',
'status': 'good',
'items': {'item': ['a', 'b']},
}
}
xml = xmltodict.unparse(payload, pretty=True, indent=' ')
print(xml)The top-level dict must have exactly one key, since XML needs a single root. Keys whose value is an empty list are skipped entirely, so {'a': []} emits no <a> element at all.
Convert values while parsingcoerce-types
def postprocessor(path, key, value):
if key in {'quantity', 'id'} and value is not None:
return key, int(value)
if key == 'active':
return key, value == 'true'
return key, value
doc = xmltodict.parse(xml, postprocessor=postprocessor)The callback runs for every element and receives the ancestor path, so you can match on position rather than name alone. Returning None drops the key from the output instead of setting it to null.
Walk a huge document without loading itstream-large-file
from gzip import GzipFile
def handle_artist(path, artist):
print(artist['name'])
return True # False stops the parse
xmltodict.parse(
GzipFile('discogs_artists.xml.gz'),
item_depth=2,
item_callback=handle_artist,
)parse() returns None in this mode; the callback is the only output. item_depth counts from the root, so depth 2 means the grandchildren of the document element.
Expand or collapse XML namespaceshandle-namespaces
namespaces = {
'http://defaultns.com/': None, # strip this prefix entirely
'http://a.com/': 'ns_a', # shorten this one
}
doc = xmltodict.parse(
xml,
process_namespaces=True,
namespaces=namespaces,
)Without process_namespaces=True the xmlns declarations are treated as ordinary attributes and prefixed names stay glued together as written. With it on, unmapped namespaces expand to the full URI in the key, which makes for very long dict keys.
Parse straight from a file handleparse-from-file
with open('feed.xml', 'rb') as fh:
doc = xmltodict.parse(fh)
# and write the result back out to a file
with open('out.xml', 'w', encoding='utf-8') as fh:
xmltodict.unparse(doc, output=fh, pretty=True)Open the input in binary mode and let expat read the encoding from the XML declaration; a text-mode handle plus a non-UTF-8 declaration is a reliable way to get a mangled parse.
Change the attribute and text key namescustom-key-style
doc = xmltodict.parse(
xml,
attr_prefix='', # attributes become plain keys
cdata_key='text', # instead of '#text'
)Dropping the @ prefix reads nicer but silently merges an attribute and a child element that share a name, with the later one winning. Keep the prefix unless you control the schema.
Keep a consistent dict shape for chosen elementsforce-cdata
xml = '<a><b>data1</b><c>data2</c></a>'
xmltodict.parse(xml, force_cdata=('b',))
# {'a': {'b': {'#text': 'data1'}, 'c': 'data2'}}
xmltodict.parse(xml, force_cdata=True)
# every element becomes a dict with '#text'This is the fix for the attributes-and-text shape problem: force_cdata on the elements that sometimes carry attributes so they are always dicts, never bare strings.
Keep text exactly as writtenpreserve-whitespace
doc = xmltodict.parse(
xml,
strip_whitespace=False,
cdata_separator=' ',
)Leading and trailing whitespace is trimmed by default, which matters for fixed-width payloads. Do not combine strip_whitespace=False with unparse(pretty=True): you get the preserved whitespace and the pretty-printer's indentation both.
Parse XML you did not produceuntrusted-input
# entity parsing is off by default; keep it that way
doc = xmltodict.parse(hostile_xml, disable_entities=True)
# for wider hardening, parse with defusedxml first
from defusedxml.ElementTree import fromstring
root = fromstring(hostile_xml)disable_entities=True blocks entity expansion attacks, but it is not a full XML threat model. Element names you pass to unparse() are handed to the standard library's XMLGenerator without validation, which is the behavior behind the disputed CVE-2025-9375.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| lxml | PyPI | You need XPath, XSLT, schema validation, or precise control over the document tree |
| defusedxml | PyPI | Parsing XML from untrusted sources where entity expansion and bomb attacks are the concern |
| xmlschema | PyPI | You have an XSD and want validation plus schema-aware conversion to typed Python values |
| untangle | PyPI | You prefer attribute access on objects (doc.root.child) over dictionary keys |