Reading and writing XML
ElementTree, attributes and text, namespaces, building XML safely, and untrusted input.
Create a free account Try part of it here
Why it matters
JSON won most APIs, but XML is still common wherever organizations exchange data. It's in news and podcast feeds, the sitemaps websites give search engines, the GPX files a GPS watch records a hike in, bank and government interfaces, and inside modern Word and Excel files (.docx and .xlsx). Sooner or later a job hands you one. Python reads and writes XML with a module in its standard library, and the only real traps are namespaces and untrusted input, both of which this lesson covers.
What you'll be able to do
- Read an XML document with ElementTree: elements, attributes and text
- Find elements in a document that uses namespaces, without getting None back
- Build XML from Python data, with escaping done for you
- Treat XML from outside as untrusted, and know what defusedxml is for
What it covers
- Elements, attributes and text
- Attribute or element?
- Namespaces: why find returns None
- Pull the points out of a GPX track
- Writing XML
- Write a sitemap from data
- XML from outside is untrusted
- Worked example: how much climbing is in a hike?
<product id="7">Tent</product>, where is the product's id?Show the answer
In an attribute of the element
XML elements can carry attributes inside the opening tag (id="7") and text between the tags (Tent). Reading XML means knowing which is which, since data can live in either.
Elements, attributes and text
XML is text with labeled boxes. Each box is an element, written as an opening tag and a closing tag around its contents: <name>Fern Hollow loop</name>. Elements nest inside each other, like folders, so the whole document is a tree with one root element at the top. An opening tag can also carry attributes, short name-value pairs written inside it: <trail id="fh-01" difficulty="easy">.
Python's xml.etree.ElementTree (imported as ET by everyone) turns that text into objects you can walk:
import xml.etree.ElementTree as ET text = """ <trails region="Cascade foothills"> <trail id="fh-01" difficulty="easy"> <name>Fern Hollow loop</name> <miles>4.2</miles> </trail> <trail id="rm-02" difficulty="hard"> <name>Red Mesa climb</name> <miles>7.5</miles> <closed>true</closed> </trail> </trails> """ root = ET.fromstring(text) print(root.tag, root.get("region")) for trail in root.findall("trail"): name = trail.find("name").text miles = float(trail.find("miles").text) print(trail.get("id"), name, miles, trail.get("difficulty"), trail.find("closed") is not None)
The four tools in that cell do almost all the reading you'll need:
element.tagis the element's name, andelement.get("id")reads an attribute (or givesNoneif it's missing).element.find("name")returns the first child with that tag, orNoneif there isn't one.element.findall("trail")returns a list of all matching children, empty if there are none.element.textis the text inside an element, always a string (orNone), so convert numbers yourself. It's only the text before the first child element: in<a>hello<b>x</b>tail</a>,a.textis'hello'.
import xml.etree.ElementTree as ET root = ET.fromstring('<trail id="pg-03"><name>Pine Gap</name><miles>3</miles></trail>') print(root.find("miles").text * 2, root.find("rating"), root.get("id"))
Show the answer
33 None pg-03
.text is the string "3", and a string times 2 repeats it: "33". There's no rating element, so find gives None (findall would give an empty list). The id is an attribute, read with get.
Attribute or element?
The same fact can be stored either way, <trail miles="4.2"> or <trail><miles>4.2</miles></trail>, and you'll read both. The usual habit when you design XML yourself: attributes for short facts about the element, such as ids, units and flags, and child elements for the content itself, anything that can repeat, and anything long or with structure of its own. A trail has one id but many waypoints, so the id is an attribute and the waypoints are elements.
To search deeper than direct children, use a path: root.findall("trail/name") finds every name inside a trail, and root.iter("name") walks the whole tree, every level, which is handy when you don't know or care where an element sits.
Short facts about an element go in attributes; content, and anything that repeats, goes in child elements.
Namespaces: why find returns None
Real formats add a namespace: a long URL attached to every tag name, so that a <name> from one standard can't be confused with a <name> from another. It's usually declared once at the top with xmlns="...", and from then on it applies to every element silently. GPX, the format GPS watches record hikes in, works this way:
import xml.etree.ElementTree as ET gpx = """<gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1" creator="trail-club"> <trk> <name>Fern Hollow loop</name> <trkseg> <trkpt lat="45.5120" lon="-122.6587"><ele>120.0</ele></trkpt> <trkpt lat="45.5131" lon="-122.6601"><ele>134.5</ele></trkpt> <trkpt lat="45.5144" lon="-122.6612"><ele>129.0</ele></trkpt> </trkseg> </trk> </gpx>""" root = ET.fromstring(gpx) print(root.tag) print(root.find("trk")) # None: the real tag includes the namespace NS = {"g": "http://www.topografix.com/GPX/1/1"} print(root.find("g:trk/g:name", NS).text) for pt in root.iterfind(".//g:trkpt", NS): print(float(pt.get("lat")), float(pt.get("lon")), float(pt.find("g:ele", NS).text))
The first print shows what's going on: ElementTree stores the tag as {http://www.topografix.com/GPX/1/1}gpx, the namespace in curly brackets, then the name. So find("trk") looks for a tag with no namespace and finds nothing. The fix is a small dictionary from a short prefix of your choice (g here) to the namespace URL, passed to find, findall and iterfind, with the prefix on every step of the path. .// means "at any depth below here". Attributes like lat usually have no namespace, so get("lat") works as before.
<rss xmlns:dc="http://purl.org/dc/elements/1.1/"> and each item has <dc:creator>Ana Ruiz</dc:creator>. What does item.find("dc:creator") return without a namespaces dictionary?Show the answer
None, because the stored tag is {http://purl.org/dc/elements/1.1/}creator
Prefixes are shorthand inside the document only, and mean nothing to find unless you pass {"dc": "http://purl.org/dc/elements/1.1/"}. A plain tag search compares names directly and quietly finds nothing; a path with a slash, such as "item/dc:creator", raises SyntaxError: prefix 'dc' not found in prefix map. Either way the fix is the namespaces dictionary.
Pull the points out of a GPX track
Write track_points(gpx_text): parse a GPX document and return a list of (lat, lon, ele) tuples of floats, one per trkpt, in order. Use a namespaces dictionary for the GPX namespace. If a point has no ele element, use None for its elevation.
import xml.etree.ElementTree as ET NS = {"g": "http://www.topografix.com/GPX/1/1"} def track_points(gpx_text): root = ET.fromstring(gpx_text) return [(p.get("lat"), p.get("lon"), None) for p in root.findall("trkpt")] gpx = """<gpx xmlns="http://www.topografix.com/GPX/1/1"><trk><trkseg> <trkpt lat="45.5120" lon="-122.6587"><ele>120.0</ele></trkpt> <trkpt lat="45.5131" lon="-122.6601"><ele>134.5</ele></trkpt> </trkseg></trk></gpx>""" print(track_points(gpx))
Writing XML
To produce XML, build the tree with ET.Element and ET.SubElement, then turn it into text with ET.tostring. Don't glue strings together: ElementTree escapes special characters for you, so a trail called "Rock & Roll" comes out as Rock & Roll and can't break the document. It's a rule that holds for every format: let the library handle the quoting, never string gluing.
Here's a sitemap, the file that tells search engines which pages a site has:
import xml.etree.ElementTree as ET SM = "http://www.sitemaps.org/schemas/sitemap/0.9" ET.register_namespace("", SM) # write it as the default namespace, with no prefix urlset = ET.Element(f"{{{SM}}}urlset") for path, day in [("/trails/fern-hollow", "2025-06-14"), ("/trails/rock-&-roll", "2025-06-20")]: url = ET.SubElement(urlset, f"{{{SM}}}url") ET.SubElement(url, f"{{{SM}}}loc").text = "https://club.example" + path ET.SubElement(url, f"{{{SM}}}lastmod").text = day ET.indent(urlset) # line breaks and spaces, for people reading it print(ET.tostring(urlset, encoding="unicode"))
The tag names use the curly-bracket form, {namespace}name (in an f-string each literal bracket is doubled, so {{{SM}}} means a bracket, the namespace, a bracket). register_namespace controls how the namespace is written in the output, and encoding="unicode" returns a str instead of bytes. Notice the & in the second address.
Write a sitemap from data
Write sitemap(pages): pages is a list of (path, last_modified) pairs. Return the sitemap as a string: a urlset root in the sitemap namespace, one url per page with a loc (https://club.example plus the path) and a lastmod, using ET.Element, ET.SubElement and ET.tostring(..., encoding="unicode"). The checks parse your output back, so the exact spacing doesn't matter.
import xml.etree.ElementTree as ET SM = "http://www.sitemaps.org/schemas/sitemap/0.9" def sitemap(pages): rows = "".join(f"<url><loc>https://club.example{p}</loc><lastmod>{d}</lastmod></url>" for p, d in pages) return f"<urlset>{rows}</urlset>" print(sitemap([("/trails/fern-hollow", "2025-06-14")]))
XML from outside is untrusted
XML has features that a hostile file can abuse. An entity is a named piece of text defined at the top of a document and reused by name. One entity can be defined as ten copies of another, that one as ten of the next, and so on, so a file of a few hundred bytes expands to gigabytes in memory: the "billion laughs" attack. Other parsers will fetch an external entity from a URL or a local file path named in the document. That can leak files from the server or make it call internal addresses, an attack called XXE.
Python's own documentation warns that its XML modules aren't secure against maliciously built data. ElementTree doesn't fetch external entities, and recent versions of the Expat parser underneath it limit entity expansion. Still, the advice for anything you didn't write yourself is simple: parse it with defusedxml, a small package with the same interface (from defusedxml.ElementTree import fromstring) that refuses entity tricks outright. It's the XML version of a general rule: data from outside is guilty until proven innocent. And check sizes before parsing: a 2 GB upload shouldn't reach the parser at all.
Show the answer
Use defusedxml, and refuse oversized files
Uploads are untrusted input. defusedxml refuses the entity tricks that make a small file explode or fetch outside data, and a size limit stops huge files before any parsing. A file name proves nothing about the contents.
Worked example: how much climbing is in a hike?
A GPX track records the elevation at each point, so the total climb is the sum of every rise between neighboring points (descents don't count). Read the track, then add up the rises.
import xml.etree.ElementTree as ET NS = {"g": "http://www.topografix.com/GPX/1/1"} gpx = """<gpx xmlns="http://www.topografix.com/GPX/1/1"><trk><name>Red Mesa climb</name><trkseg> <trkpt lat="45.40" lon="-122.50"><ele>210</ele></trkpt> <trkpt lat="45.41" lon="-122.51"><ele>260</ele></trkpt> <trkpt lat="45.42" lon="-122.52"><ele>245</ele></trkpt> <trkpt lat="45.43" lon="-122.53"><ele>330</ele></trkpt> <trkpt lat="45.44" lon="-122.54"><ele>395</ele></trkpt> </trkseg></trk></gpx>""" root = ET.fromstring(gpx) name = root.find("g:trk/g:name", NS).text heights = [float(e.text) for e in root.iterfind(".//g:trkpt/g:ele", NS)] climb = sum(max(0, b - a) for a, b in zip(heights, heights[1:])) print(name, heights, "climb:", climb, "m")
The path .//g:trkpt/g:ele finds every elevation inside a track point at any depth, with the namespace prefix on both steps. zip(heights, heights[1:]) pairs each point with the next one, and max(0, b - a) keeps only rises: 50 + 0 + 85 + 65 = 200 meters of climbing, though the hike ends only 185 meters above its start. That gap is why trail guides report total climb, not just the difference between start and finish.
Practice what you just read
- 4 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
root.find("entry") returns None on an Atom feed (a common XML format for news feeds) that clearly contains entry elements. What's the most likely reason?