Quickstart
July 27, 2026 · View on GitHub
XML.jl
Read and write XML in pure Julia.
Upgrading from XML.jl 0.3 to 0.4? See the migration guide.
Quickstart
using XML
filename = joinpath(dirname(pathof(XML)), "..", "test", "data", "books.xml")
doc = read(filename, Node)
# Document (4 children)
children(doc)
# 4-element Vector{Node{String}}:
# Declaration <?xml version="1.0"?>
# Text "\n"
# Element <catalog> (25 children)
# Text "\n"
The Text "\n" children are real: whitespace between elements is part of the document, and XML.jl 0.4 keeps it (0.3 dropped it — see the migration guide). Use elements (or the lazy iterator eachelement) when you want the child elements only:
catalog = only(elements(doc)) # the root element
# Element <catalog> (25 children)
elements(catalog)[2] # second <book> element
# Element <book id="bk102"> (13 children)
Choosing a reader
XML.jl ships four readers behind one set of accessors (nodetype, tag, attributes,
value, children, eachelement, …). Pick by what you do with the document:
| Reader | DOM materialized? | Revisit a node | Mutable | GC cost |
|---|---|---|---|---|
Cursor | no (nothing) | impossible (forward-only) | — | ~0 |
LazyNode | virtual | re-decode per visit (pay-per-traversal) | no | ~0 |
FlatNode (experimental) | yes, compact (columnar) | O(1), paid once | no | ~0 |
Node | yes, objects | O(1), paid once | yes | high |
Rules of thumb:
- one forward pass →
Cursor(fastest scan, ~zero allocation) - extract a little from a large document →
LazyNode(opening is free; you pay per node touched) - read-heavy full document, repeated traversals →
FlatNode(the random-access read API ofNodeat almost none of its GC cost: fastest build and walk, O(1) revisits) - build or edit documents →
Node
FlatNode and LazyNode retain the source string as long as any handle lives. Measured numbers: see Performance by access pattern.
Node Interface
Every node in the XML DOM is represented by Node, a single type parametrized on its string storage.
nodetype(node) -> XML.NodeType (an enum)
tag(node) -> String or Nothing
attributes(node) -> XML.Attributes{String} or Nothing
value(node) -> String or Nothing
children(node) -> Vector{Node}
is_simple(node) -> Bool (e.g. <tag>text</tag>)
simple_value(node) -> e.g. "text" from <tag>text</tag>
Indexing follows one rule across the tree readers (Node, LazyNode, FlatNode): the
key's type selects the axis. An Int indexes the child sequence (node[2], node[end]),
: takes it whole (node[:]), and a String looks up the attribute map (node["id"],
KeyError on a miss) — with get(node, "id", default) as the default-on-a-miss variant,
and haskey/keys alongside. Cursor supports the attribute forms on its current node.
NodeType
Each item in an XML DOM is classified by its NodeType:
| NodeType | XML Representation | Constructor |
|---|---|---|
Document | An entire document | Document(children...) |
DTD | <!DOCTYPE ...> | DTD(...) |
Declaration | <?xml attributes... ?> | Declaration(; attrs...) |
ProcessingInstruction | <?tag attributes... ?> | ProcessingInstruction(tag; attrs...) |
Comment | <!-- text --> | Comment(text) |
CData | <![CDATA[text]]> | CData(text) |
Element | <tag attrs...> children... </tag> | Element(tag, children...; attrs...) |
Text | the text part of <tag>text</tag> | Text(text) |
Mutation
push!(parent, child) # Add a child
parent[2] = child # Replace a child
node["key"] = "value" # Add/change an attribute
node["key"] # Get an attribute
Tree Navigation
depth(child, root) # Depth of child relative to root
parent(child, root) # Parent of child within root's tree
siblings(child, root) # Siblings of child within root's tree
Writing Elements with XML.h
Similar to Cobweb.jl, XML.h enables you to write elements with a simpler syntax:
using XML: h
node = h.parent(
h.child("first child content", id="id1"),
h.child("second child content", id="id2")
)
# Element <parent> (2 children)
print(XML.write(node))
# <parent>
# <child id="id1">first child content</child>
# <child id="id2">second child content</child>
# </parent>
Reading
Every reader shares the same entry points — the reader is the second argument:
read(filename, Node) # or LazyNode, FlatNode, Cursor
parse(str, Node) # or LazyNode, FlatNode, Cursor
Cursor can also be constructed directly from any AbstractString: Cursor(str).
Writing
XML.write(filename::String, node) # write to file
XML.write(io::IO, node) # write to stream
XML.write(node) # return String
XML.write respects xml:space="preserve" on elements, suppressing automatic indentation — whitespace between child elements is written verbatim instead of being reformatted:
with = parse("<msg xml:space=\"preserve\"><b>A</b> <b>B</b></msg>", Node)
without = parse("<msg><b>A</b> <b>B</b></msg>", Node)
print(XML.write(only(elements(with)))) # byte-identical round-trip
# <msg xml:space="preserve"><b>A</b> <b>B</b></msg>
print(XML.write(only(elements(without)))) # pretty-printed — the three spaces are gone
# <msg>
# <b>A</b>
# <b>B</b>
# </msg>
XML.write accepts Node, LazyNode (zero-copy of the original source; normalize=true re-parses and pretty-prints) and FlatNode (materializes through Node first). Building and editing documents is Node-only — the other readers are read-only.
XPath
Query nodes using a subset of XPath 1.0 via xpath(node, path):
doc = parse("""
<root>
<a id="1"><b>hello</b></a>
<a id="2"><b>world</b></a>
</root>
""", Node)
root = doc[1] # the <root> element (doc[end] would be the trailing-whitespace Text node)
xpath(root, "//b") # All <b> descendants
xpath(root, "a[@id='2']/b") # <b> inside <a id="2">
xpath(root, "a[1]") # First <a> child
xpath(root, "//b/text()") # Text nodes inside all <b>s
Supported syntax
| Expression | Description |
|---|---|
/ | Root / path separator |
tag | Child element by name |
* | Any child element |
// | Descendant-or-self (recursive) |
. | Current node |
.. | Parent node |
[n] | Positional predicate (1-based) |
[@attr] | Has-attribute predicate |
[@attr='v'] | Attribute-value predicate |
text() | Text node children |
node() | All node children |
@attr | Attribute value (as a Text node) |
Cursor
The forward, StAX-style pull reader: one mutable cursor walks the document in document order — no tree, one tokenizing scan, ~zero allocation. Advance with next!; read the current position with the usual accessors (nodetype, tag, attributes, value, depth):
cur = Cursor(xml_string)
while next!(cur) !== nothing
nodetype(cur) === Element && tag(cur) == "book" && println(attributes(cur))
end
From a file or stream: read(filename, Cursor) / read(io, Cursor), with the same byte-level BOM normalization as the tree readers (UTF-8 BOM strip, UTF-16 transcoding). The Cursor(str) constructor also accepts any AbstractString directly, including a StringView over Mmap for files too large for the heap (same recipe as under LazyNode below).
Two structured helpers carry the depth bookkeeping for you: for_each_child(f, cur) applies f to each immediate child of the current node (nestable — composing calls gives a full depth-first walk), and skip_element!(cur) jumps past the current element's entire subtree in one byte-level scan — the cheap way to classify nodes without tokenizing their contents. @for_each_child is the inlined-body macro form for hot extraction loops, and Cursor(data, startpos) starts a cursor at a known byte offset to walk just a subtree.
A Cursor is one object that mutates as it advances: every variable referring to it sees its current position, so read what you need while the loop is on the node. To keep a node beyond further next! calls, take an immutable snapshot of the position with LazyNode(cur) — the accumulate-while-scanning idiom: push!(found, LazyNode(cur)) keeps each hit, where push!(found, cur) would collect many references to one moved object. (XLSX.jl v0.12 uses exactly this snapshot to hold on to <sheetData> before skip_element! jumps past it.)
LazyNode
For read-only access without building a full DOM tree, use LazyNode. It stores only a reference to the source string and re-tokenizes on demand, using significantly less memory:
doc = parse(xml_string, LazyNode)
doc = read("file.xml", LazyNode)
LazyNode supports the same read-only interface as Node: nodetype, tag, attributes, value, children, is_simple, simple_value, plus integer and string indexing.
LazyNode is for partial reads only: opening is a no-op wrapper (~0.5 µs whatever the file size), a traversal pays only for the bytes it steps over — descending to a target is O(bytes before it), so reaching this 14 MB file's root element costs ~4 µs — and nothing is cached, so repeated visits pay again. Unbeatable for descents and kilobyte-scale documents; for whole-tree walks (276 ms vs ~55/~106 ms build+walk on the 14 MB corpus below) and repeated queries, build FlatNode or Node instead — see Performance by access pattern.
For streaming and high-throughput workloads, several extra accessors avoid materializing intermediate collections:
sourcetext(n) # zero-copy SubString view of the node's raw source bytes
sourcespan(n) # where that text lives: a range of valid character indices
splicetext(n, repl) # the document text with this node's text replaced ("" default)
eachchildnode(n) # lazy iterator over children — no Vector allocation
children!(buf, n) # collect children into a reusable buffer
eachattribute(n) # lazy iterator over attribute name=>value pairs
is_simple_value(n) # combined is_simple + simple_value (one tokenizer pass)
get(n, key, default) # single-attribute read without building Attributes
XML.write(n) # zero-copy: returns node's original source text
XML.write(n; normalize=true) # re-parse + pretty-print, collapses source whitespace
Memory-mapped files
Memory mapping is optional — LazyNode's laziness is about node materialization, and it pays off on in-memory strings just as well. For files too large to read into heap memory, combine it with a StringView over Mmap:
using XML, Mmap, StringViews
doc = open("very_large.xml") do io
sv = StringView(Mmap.mmap(io))
parse(sv, LazyNode)
end
FlatNode (experimental)
The whole document parsed once into a contiguous columnar store — the random-access read API of Node at almost none of its GC cost: fast build, O(1) random access and O(1) parent, and the garbage collector sees a handful of arrays instead of one object per node.
doc = parse(xml_string, FlatNode) # or read("file.xml", FlatNode)
root = only(eachelement(doc))
for el in eachelement(root)
tag(el), attributes(el), value(el)
end
Same read-only accessor surface as the other readers, including sourcetext(node) — the zero-copy SubString of a node's original source text — its positional counterpart sourcespan(node), the range of valid character indices that text occupies (O(1) here, from the stored spans), and splicetext(node, replacement) — the document source with that text replaced; all three are available on the two readers that retain the source (FlatNode, LazyNode) and not on Node. By design: read-only — creating or modifying documents is Node's job; any live handle retains the whole store and source string; documents are limited to 2 GiB. Node(flatnode) materializes a handle (and its subtree) as a mutable Node; XML.write accepts a FlatNode directly. Positional identity — "same node of the same document" — is issamenode(a, b), defined like sourcetext for the two handle readers (FlatNode, LazyNode); it does not apply to Node, a self-contained value with no document anchor.
Experimental — new in v0.4.2: API details may still change in a 0.4.x release while ecosystem usage settles. Feedback welcome on the issue tracker.
Under the hood
All four readers sit on XML.XMLTokenizer, a zero-allocation streaming tokenizer (a token is a kind plus a byte range into the source). It is usable directly for token-level tooling — see the XML.XMLTokenizer module docstrings.
AbstractTrees Integration
Loading AbstractTrees alongside XML enables tree-walking utilities (print_tree, PreOrderDFS, Leaves, etc.) on both Node and LazyNode:
using XML, AbstractTrees
doc = parse("<a><b/><c><d/></c></a>", Node)
print_tree(doc)
# Document
# └─ <a>
# ├─ <b>
# └─ <c>
# └─ <d>
for n in PreOrderDFS(doc)
nodetype(n) == Element && println(tag(n))
end
Performance by access pattern
One number cannot rank the readers — cost depends on what you do with the document. Same ~14 MB / 882 K-node XMark document as the cross-library table below, XML.jl v0.4.2 (source: benchmarks/flatnode_bench.jl; lower is better; median of repeated runs at the default settings):
| build | walk every node | extract all values | DOM size in memory | |
|---|---|---|---|---|
Cursor | — (streams) | 35.1 ms (its one scan) | — | — (no DOM) |
LazyNode | ~0 (a wrapper) | 276 ms (re-tokenizes) | — | — (source only) |
FlatNode | 51.8 ms | 3.0 ms | 10.6 ms | 54.9 MiB |
Node | 99.9 ms | 6.3 ms | 6.5 ms | 80.0 MiB |
| EzXML (libxml2) | 37.7 ms | — | — | — |
Reading the table: Cursor's walk is its parse — one tokenizing scan, nothing retained. LazyNode opens for free and pays per node visited — unbeatable for touching a fraction of a large document, and (as the walk column shows) the wrong tool for visiting all of it. FlatNode$ \text{builds} ~2 \times \text{faster} \text{than} $Node$, \text{walks} ~2 \times \text{faster}, \text{holds} ~30% \text{less} \text{memory}, \text{and} \text{its} $parent/depth are O(1) where Node searches from the root; pure value extraction on an already-built tree is the one pattern where Node's direct fields win. libxml2 still builds fastest — the remaining gap is materialization, not scanning (see PERFORMANCE-v0.4.md).
Measured 2026-07-22, Apple M5 (single-threaded), Julia 1.12.6, EzXML 1.2.3.
Benchmarks
Source: benchmarks/benchmarks.jl. Data: books.xml (~4 KB) and a generated XMark auction document (~14 MB). The XML.jl column uses Node throughout — the full mutable DOM, the like-for-like counterpart of the libxml2 DOMs the C wrappers build; Performance by access pattern above shows how the other readers change the picture. Median time, lower is better.
| Benchmark | XML.jl | EzXML | LightXML | XMLDict |
|---|---|---|---|---|
| Parse, small | 0.022 ms | 0.013 ms | 0.011 ms | 0.110 ms |
| Parse, medium | 110 ms | 46.8 ms | 47.1 ms | 348 ms |
| Write, small | 0.0056 ms | 0.0059 ms | 0.059 ms | — |
| Write, medium | 27.7 ms | 21.1 ms | 29.1 ms | — |
| Collect tags, small | 0.00037 ms | 0.0011 ms | 0.0018 ms | — |
| Collect tags, medium | 5.63 ms | 10.4 ms | 12.9 ms | — |
EzXML and LightXML wrap libxml2 (C): faster on raw parse, slower on in-Julia traversal. Times include garbage collection; PERFORMANCE breaks each of its rows into the stable GC-free work and the per-session GC share.
For the per-access-pattern decomposition (streaming / partial reads / full DOM / stage breakdown) and the theory behind these numbers, see PERFORMANCE-v0.4.md.
Measured 2026-07-22, Apple M5 (single-threaded), Julia 1.12.6; EzXML 1.2.3 / LightXML 0.9.3 (libxml2 2.15.3), XMLDict 0.4.2. Source: benchmarks/benchmarks.jl.