Quickstart

July 27, 2026 · View on GitHub

CI codecov

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:

ReaderDOM materialized?Revisit a nodeMutableGC cost
Cursorno (nothing)impossible (forward-only)~0
LazyNodevirtualre-decode per visit (pay-per-traversal)no~0
FlatNode (experimental)yes, compact (columnar)O(1), paid onceno~0
Nodeyes, objectsO(1), paid onceyeshigh

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 of Node at 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:

NodeTypeXML RepresentationConstructor
DocumentAn entire documentDocument(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...)
Textthe 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

ExpressionDescription
/Root / path separator
tagChild 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
@attrAttribute 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):

buildwalk every nodeextract all valuesDOM size in memory
Cursor— (streams)35.1 ms (its one scan)— (no DOM)
LazyNode~0 (a wrapper)276 ms (re-tokenizes)— (source only)
FlatNode51.8 ms3.0 ms10.6 ms54.9 MiB
Node99.9 ms6.3 ms6.5 ms80.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.

BenchmarkXML.jlEzXMLLightXMLXMLDict
Parse, small0.022 ms0.013 ms0.011 ms0.110 ms
Parse, medium110 ms46.8 ms47.1 ms348 ms
Write, small0.0056 ms0.0059 ms0.059 ms
Write, medium27.7 ms21.1 ms29.1 ms
Collect tags, small0.00037 ms0.0011 ms0.0018 ms
Collect tags, medium5.63 ms10.4 ms12.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.