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.
This is an independent personal project, not affiliated with, sponsored by, or endorsed by any company. All views expressed are my own.
Licensed under the MIT License.