Index Ai Academy
Phase 7 · Software engineering · Python in depth

Reading and writing XML

ElementTree, attributes and text, namespaces, building XML safely, and untrusted input.

About 35 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Elements, attributes and text
  2. Attribute or element?
  3. Namespaces: why find returns None
  4. Pull the points out of a GPX track
  5. Writing XML
  6. Write a sitemap from data
  7. XML from outside is untrusted
  8. Worked example: how much climbing is in a hike?
Quick check
In <product id="7">Tent</product>, where is the product's id?
  1. In the closing tag
  2. In the element's text
  3. In a child element called id
  4. In an attribute of the element
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)
It runs right here in your browser, with nothing to install.

The four tools in that cell do almost all the reading you'll need:

  • element.tag is the element's name, and element.get("id") reads an attribute (or gives None if it's missing).
  • element.find("name") returns the first child with that tag, or None if there isn't one.
  • element.findall("trail") returns a list of all matching children, empty if there are none.
  • element.text is the text inside an element, always a string (or None), so convert numbers yourself. It's only the text before the first child element: in <a>hello<b>x</b>tail</a>, a.text is 'hello'.
Quick check
What does this print?
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"))
  1. 6 0 None
  2. 33 None pg-03
  3. 33 [] pg-03
  4. 6 None pg-03
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.

Try it now

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.

Quick check
A feed starts <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?
  1. None, because the stored tag is {http://purl.org/dc/elements/1.1/}creator
  2. The creator element, since the tag is written dc:creator in the file
  3. An empty list, because find always returns a list
  4. The text "Ana Ruiz", since find returns the element's text
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.

Drill

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))
In the lesson, your code is checked as you write it.

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 &amp; 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 &amp; in the second address.

Drill

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")]))
In the lesson, your code is checked as you write it.

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.

Quick check
Your service accepts GPX uploads from users and parses them with ElementTree. What's the most important change before launch?
  1. Parse each file twice and compare
  2. Convert the files to JSON before parsing
  3. Accept only names ending in .gpx
  4. Use defusedxml, and refuse oversized files
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

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")
In the lesson you can change this and run it.

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

Create a free account

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?

  1. A default namespace makes the real tag {namespace}entry
  2. find only searches the last child element of the root
  3. ElementTree can't read Atom feeds, only plain XML files
  4. The entries are attributes of feed, not child elements

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson