General-purpose XML parsing in pure Mojo — an xml.etree.ElementTree-shaped API. No Python dependencies, no FFI.
As of mid-2026 the Mojo ecosystem has JSON, TOML, CSV, and YAML parsers, and a
feed parser (mojo-feed) — but no
general-purpose XML library. mojo-xml fills that gap: parse any XML document
into a tree of Elements and walk it with the API Python developers already
know from xml.etree.ElementTree. It's built on the same fuzzed pull parser
that powers mojo-feed, with a proper scoped-namespace DOM layer on top.
If you know Python's xml.etree.ElementTree, the API is nearly 1:1:
Python (xml.etree.ElementTree) |
mojo-xml |
|---|---|
root = ET.fromstring(xml) |
var root = fromstring(xml) |
root.findall("book") |
root.findall("book") |
el.get("id") |
el.get("id") |
el.findtext("title") |
el.findtext("title") |
root.iter("title") |
root.iter("title") |
ET.tostring(el) |
tostring(el) |
ET.SubElement(parent, "tag") |
SubElement(parent, "tag") |
- Parse to a tree:
fromstring(text)returns the rootElement; every element carriestag,attrib,text,tail, andchildren— the exact ElementTree model, including thetext/tailsplit that trips up most hand-rolled parsers (<a>t0<b/>t1</a>→a.text="t0",b.tail="t1"). - Namespaces, resolved properly: prefixes and default
xmlnsresolve to Clark notation (<a:foo xmlns:a="http://x">→ tag{http://x}foo), with a scoped xmlns stack so nested prefix redefinition is honored. Default namespaces apply to element names but not unprefixed attributes, per the XML namespaces spec. - Query:
find/find_opt/findall/findtext/iterwith a pragmatic ElementTree path subset —tag,a/b/c,*,.//tag,.//a/b, leading./, and Clark-notation{uri}local. - Serialize:
tostring(elem)writes well-formed XML back out, escaping text and attribute values and declaring collected namespaces on the root.fromstring(tostring(e))round-trips to a structurally-equal tree. - Entities, CDATA, comments, PIs, DOCTYPE, and encoding normalization (UTF-16/BOM/Latin-1/Windows-1252) — inherited from the underlying pull parser.
- Strict by default: like ElementTree,
fromstringrejects malformed input — mismatched or stray end tags, unclosed elements, undefined entities, multiple document elements (junk after the root), non-whitespace text outside the root, and unbound namespace prefixes — rather than silently recovering. (The reservedxml:prefix is implicitly bound and needs no declaration.)
- Full XPath.
find/findallsupport the common ElementTree path subset above, not predicates, axes, or functions. - DTD / schema validation. Well-formedness is checked; validation against a DTD or XSD is out of scope for v0.1.
- Streaming for unbounded input.
fromstringbuilds a full in-memory tree. For event-at-a-time processing over huge documents, drop down to theXmlPullParser(also exported) — the same pull API mojo-feed uses.
With pixi:
pixi install
pixi run test
pixi run demoOr with uv:
uv venv
uv pip install mojo --index https://whl.modular.com/nightly/simple/ --prerelease allow
.venv/bin/mojo run -I src test/test_etree.mojoRequires a Mojo nightly (>=1.0.0b3).
from xml import fromstring, tostring, Element
def main() raises:
var root = fromstring(String(
"<catalog><book id='b1'><title>Mojo</title></book></catalog>"
))
print(root.tag) # catalog
var book = root.find("book") # first <book>
print(book.get("id")) # b1
print(book.findtext("title")) # Mojo
for t in root.iter("title"): # every <title> at any depth
print(t.text)
print(tostring(root)) # serialize back to XMLNamespaced documents resolve to Clark notation:
var svg = fromstring(open("logo.svg", "r").read())
# svg.tag == "{http://www.w3.org/2000/svg}svg"
for rect in svg.iter("{http://www.w3.org/2000/svg}rect"):
print(rect.get("width"), rect.get("height"))find raises if there's no match; find_opt returns an Optional[Element]:
var maybe = root.find_opt("missing")
if maybe:
print(maybe.value().tag)Byte-for-byte against CPython. test/anchor_run.py parses a corpus of real
general-XML documents (SVG, a Maven pom.xml, a sitemap, a SOAP envelope, an
Android layout, an entity-heavy config, an Atom feed, an RSS/podcast feed, an
XHTML fragment, deeply-nested mixed content with comments and PIs, a CDATA
document, one with attributes in multiple namespaces, an empty-element-heavy
document, and one with CRLF line endings and multi-line attribute values) two
ways — with mojo-xml and with Python's own xml.etree.ElementTree — and dumps
each tree in an identical canonical format. All 14 match element-for-element
in strict, whitespace-exact mode: Clark-notation namespaces (including the
reserved xml: prefix), sorted attributes, the text/tail model, XML 1.0
line-ending and attribute-value normalization, and entity decoding are identical
to CPython.
pixi run test # 65 DOM tests + 39 pull-parser tests
python3 test/anchor_run.py --strip # optional: env MOJO=<mojo binary>Tests. 104 total: test/test_etree.mojo (65 — parsing, text/tail, entities,
CDATA, namespaces, find/findall/findtext/iter, mutation, serialization and
escaping, malformed-input error cases) and test/test_pull.mojo (39 — the
underlying tokenizer).
Fuzzing. test/fuzz_drive.py mutates the corpus plus 18 adversarial
synthetics (deep nesting, wide fan-out, namespace bombs, entity/char-ref
floods, malformed UTF-8, huge attribute counts, oversized tokens, and
comment/CDATA/newline edge cases): 6,000 iterations with zero crashes and
zero hangs — malformed input either parses or raises a clean error. Hostile
input is bounded: element nesting is capped (MAX_DEPTH = 512) so a
pathologically deep document raises instead of driving the tree-walking APIs
into quadratic time.
iterandtostringreturn owned copies, not references (Mojo's ownership model). Cost is linear in the visited subtree for any real document; theMAX_DEPTHcap bounds the worst case on adversarial deep chains.SubElement(parent, tag)returns a snapshot copy, not a live reference intoparent.children. To build a rich child, construct it fully, thenparent.append(child^).- Serialization order of attributes and generated namespace prefixes
follows
Dictinsertion order; round-trip structural equality holds regardless of order. - Namespace scoping is lexical (pushed/popped as elements open and close) — stronger than the document-flat resolution the feed parser uses.
Eleven pure-Mojo libraries that mirror familiar Python stdlib and PyPI APIs, filling gaps in the native Mojo ecosystem:
- mojo-feed — RSS/Atom/JSON Feed
parsing (Python's
feedparser); shares this library's pull parser - mojo-captions — SRT and WebVTT subtitle/transcript parsing (no Python stdlib parallel)
- mojo-html — HTML parsing and
article extraction (Python's
readability) - mojo-markdown —
CommonMark markdown parsing (Python's
markdown) - mojo-unicodedata —
Unicode normalization and case folding (Python's
unicodedata) - mojo-url — URL parsing and
encoding (Python's
urllib.parse) - mojo-diff — text diffing
(Python's
difflib) - mojo-template — a
Jinja-flavored template engine (Python's
jinja2) - mojo-tar — tar archive
reading and writing (Python's
tarfile) - mojo-redis — a Redis
client (Python's
redis-py)
Issues and PRs welcome — especially real-world XML that parses differently from
xml.etree (attach the document or a snippet) and path-matching gaps. Run
pixi run test and python3 test/anchor_run.py before sending a PR.
Built by Conor Bronsdon — host of Chain of Thought, a podcast about AI agents, infrastructure, and engineering. Find me on X or LinkedIn.
All views, opinions, and statements expressed on this account/in this repo are solely my own and are made in my personal capacity. They do not reflect, and should not be construed as reflecting, the views, positions, or policies of Modular. This account is not affiliated with, authorized by, or endorsed by my employer in any way.
Licensed under the MIT License.