XML
The xml module reads and writes namespace-aware XML 1.0. One parser serves three ways of working:
| You have | Use | Memory |
|---|---|---|
| A small document | xml.decode |
The whole tree |
| A large document made of repeated elements | reader:select |
One batch of records |
| Anything else, or very large values | The cursor: reader:next |
A small read-ahead window (64 KiB) |
The writer mirrors the reader, so a read-transform-write pipeline runs in constant memory.
Loading
local xml = require("xml")
Add xml to the executable entry's modules: list before requiring it.
Elements
Every tree the module produces or accepts is built from one plain table shape. Elements carry no metatable, so they pass through messages, payloads and json.encode unchanged.
type Element = {
name: string, -- local name
ns: string?, -- namespace URI; absent = no namespace
attrs: {[string]: string}?, -- attribute values
xmlns: {[string]: string}?, -- namespace declarations: prefix -> URI, "" = default
text: string?, -- content, when it is text only
children: {string | Element}?, -- content, when it holds at least one element
}
local doc, err = xml.decode('<order id="7"><total>12.50</total><note>rush</note></order>')
if err then return nil, err end
doc.name -- "order"
doc.attrs.id -- "7"
doc.children[1].name -- "total"
doc.children[1].text -- "12.50"
Rules that always hold:
- An element has
text, orchildren, or neither. Never both. childrenlists text and elements in document order. Adjacent text is one string.<a/>and<a></a>are the same element.- All values are strings. Numbers and booleans are never inferred.
- Comments and processing instructions are not part of a tree.
Walking mixed content means telling text from elements:
local function visit(el: xml.Element)
for _, child in ipairs(el.children or {}) do
if type(child) == "table" then
visit(child)
else
print(child)
end
end
end
Namespaces
Names are resolved while parsing. name is the local name and ns is the namespace URI; the prefixes a document happens to use are not part of the data.
local doc = xml.decode('<s:item xmlns:s="urn:shop" xmlns:x="urn:ext" id="7" x:rev="2"/>')
doc.name -- "item"
doc.ns -- "urn:shop"
doc.attrs.id -- "7"
doc.attrs["{urn:ext}rev"] -- "2"
xml.attr(doc, "rev", "urn:ext") -- "2"
doc.xmlns -- {s = "urn:shop", x = "urn:ext"}
- An unqualified attribute is keyed by its local name. A qualified attribute is keyed
{uri}local. - Unprefixed attributes have no namespace; the default namespace applies to elements only.
xmlnsholds the declarations written on that element. They never appear inattrs.- The root of a tree returned by
decode,reader:nodeorrecords:readcarries all bindings in scope, so a record cut out of a large document still resolves prefixed values. Elements reached throughchild,childrenorfindcarry only their own declarations.
-- <s:item xsi:type="s:Book"> ... </s:item>
local name, ns = xml.resolve(item, item.attrs["{http://www.w3.org/2001/XMLSchema-instance}type"])
-- "Book", "urn:shop"
Decoding and Encoding
decode
Parse one complete document:
local doc, err = xml.decode(body, {whitespace = "strip"})
if err then return nil, err end
| Parameter | Type | Description |
|---|---|---|
data |
string | XML document |
options |
table? | Reader options |
Returns: Element, error
The document is bounded by max_node_bytes (8 MiB by default). Use a reader for anything larger.
encode
Serialize an element as UTF-8:
local out, err = xml.encode({
name = "order",
attrs = {id = "7"},
children = {
{name = "total", text = "12.50"},
{name = "note", text = "rush"},
},
}, {indent = " ", declaration = true})
<?xml version="1.0" encoding="UTF-8"?>
<order id="7">
<total>12.50</total>
<note>rush</note>
</order>
| Parameter | Type | Description |
|---|---|---|
element |
Element | Tree to serialize |
options |
table? | Writer options |
Returns: string, error
Output is deterministic: namespace declarations come first in prefix order, then attributes ordered by namespace and name. Prefixes come from the xmlns tables and the ns option. An element whose namespace has no binding declares it as the default namespace; a qualified attribute gets a generated prefix (ns1, ns2, ...).
The element must follow the rules above: an empty text, an empty attrs, xmlns or children table, or a gap in children is an error with code element. Unknown fields are ignored.
Helpers
Helpers work on element tables. A missing node is nil or an empty list, never an error.
| Function | Returns | Description |
|---|---|---|
xml.text(element, options?) |
string | Text of the element and all descendants |
xml.attr(element, name, ns?) |
string? | Attribute by local name and namespace |
xml.child(element, name, ns?) |
Element? | First child element with that name |
xml.children(element, name?, ns?) |
{Element} | Child elements, optionally filtered |
xml.find(element, path, options?) |
{Element}?, error | Descendants matching a path; options.ns binds path prefixes |
xml.resolve(element, qname) |
string, string?, error | Local name and namespace of a prefixed value |
local total = xml.child(doc, "total")
local amount = total and total.text
local lines, err = xml.find(doc, "lines/line")
if err then return nil, err end
for _, line in ipairs(lines) do
print(xml.attr(line, "sku"), xml.text(line))
end
For child and children, omitting ns matches any namespace and ns = "" matches elements without one.
text and find walk the whole subtree and stop at a content budget, max_node_bytes (8 MiB by default, 4 units per element and 1 per byte of text) and max_depth (256). A tree returned by decode under default limits always fits. Pass {max_node_bytes = n} for a larger tree. Past the budget find returns a limit error and text raises one, which is also how a table that contains itself is reported.
Paths
Paths select elements by resolved name. xml.find and reader:select use them.
| Step | Matches |
|---|---|
item |
item without a namespace |
s:item |
item in the namespace bound to s in the ns option |
*:item |
item in any namespace |
s:* |
Any element in the namespace bound to s |
* |
Any element |
Prefixes in a path come from the ns option you pass ({ns = {s = "urn:shop"}}), not from the document. An unbound prefix is an error. In reader:select the first step matches the document root; in xml.find it matches children of the given element.
Reading Large Documents
reader
Create a reader over a string or any readable stream object, such as a stream or an open file:
local reader, err = xml.reader(stream, {whitespace = "strip"})
if err then return nil, err end
| Parameter | Type | Description |
|---|---|---|
source |
string | Stream | File | Document, readable stream or open file |
options |
table? | Reader options |
Returns: Reader, error
The reader reads ahead. Until it is closed, nothing else may read the stream. Closing the reader leaves the stream open.
Records
Most large documents are a wrapper around many repeated elements. select delivers those elements whole, in batches, and keeps nothing else:
local records, err = reader:select("s:catalog/s:item", {ns = {s = "urn:shop"}})
if err then return nil, err end
while true do
local batch, err = records:read(100)
if err then return nil, err end
if not batch then break end
for _, item in ipairs(batch) do
import_item(item.attrs and item.attrs.id, xml.child(item, "title", "urn:shop"))
end
end
records:close()
| Method | Returns | Description |
|---|---|---|
reader:select(paths, options?) |
Records, error | paths is one path or a list; options.ns binds path prefixes |
records:read(n?) |
{Element}?, error | Up to n records (default 64); nil at the end of the document |
records:close() |
boolean | Release the selection and its reader |
- Records arrive in document order across all paths.
- Matches do not nest: an element inside a delivered record is not delivered again.
- A batch also ends once its records span
max_node_bytesof source. - An error is sticky: every later
readreturns it.
Cursor
The cursor visits the document one event at a time. Use it for irregular documents, for values too large to hold, and for transforms.
while true do
local kind, err = reader:next()
if err then return nil, err end
if not kind then break end
if kind == "start" and reader:name() == "item" then
if reader:attr("status") == "active" then
local item, err = reader:node()
if err then return nil, err end
handle(item)
else
reader:skip()
end
end
end
reader:close()
| Method | Returns | Description |
|---|---|---|
reader:next() |
string?, error | "start", "end", "text", "comment" or "pi"; nil at the end |
reader:kind() |
string? | Current event kind |
reader:name() |
string? | Local name on start/end; target on pi |
reader:ns() |
string? | Namespace URI on start/end |
reader:attr(name, ns?) |
string? | One attribute of the current start |
reader:attrs() |
table | All attributes, keyed as in Element.attrs |
reader:text() |
string? | Content on text, comment and pi |
reader:depth() |
integer | Open elements, counting the current start |
reader:pos() |
table | {line, column, offset} of the current event |
reader:node() |
Element, error | On start: build the element, land on its end |
reader:skip() |
boolean, error | On start: discard the element, land on its end |
reader:copy(writer) |
boolean, error | On start: write the element to a writer, land on its end |
reader:close() |
boolean | Release the reader |
Text longer than max_token_bytes arrives as consecutive text events split on character boundaries, so a multi-gigabyte value never has to fit in memory. comment and pi events appear only when the comments and instructions options are set.
Stopping before the end does not validate the rest of the document.
Writing
writer
Create a writer over a writable stream or open file, or in memory when no destination is given:
local w, err = xml.writer(out, {indent = " "})
if err then return nil, err end
w:start_element("items", {generated = "2026-01-01"})
w:element("item", {id = "1"}, "first")
w:node({name = "item", attrs = {id = "2"}, text = "second"})
w:end_element()
local _, err = w:finish()
| Method | Returns | Description |
|---|---|---|
w:start_element(name, attrs?, ns?) |
boolean, error | Open an element |
w:end_element() |
boolean, error | Close the innermost open element |
w:text(data) |
boolean, error | Write escaped text |
w:element(name, attrs?, text?, ns?) |
boolean, error | Write a complete element |
w:node(element) |
boolean, error | Write a whole tree |
w:comment(data) |
boolean, error | Write a comment |
w:flush() |
boolean, error | Send buffered output to the stream |
w:finish() |
string?, error | Complete the document; returns it for an in-memory writer |
w:close() |
boolean | Release the writer; the stream stays open |
finish fails if elements are still open or no root element was written, and can be called once. flush on an in-memory writer does nothing. close never completes an unfinished document. A failed write is final: output already sent is not replayed, and every later call returns the same error.
Transforming without loading
reader:copy moves an element from a reader to a writer without building it, re-declaring the namespaces it inherits:
local reader = xml.reader(input)
local w = xml.writer(output)
w:start_element("active")
while true do
local kind, err = reader:next()
if err then return nil, err end
if not kind then break end
if kind == "start" and reader:depth() == 2 then
if reader:attr("status") == "active" then
local _, err = reader:copy(w)
if err then return nil, err end
else
reader:skip()
end
end
end
w:end_element()
w:finish()
Options
Reader options
| Field | Type | Default | Description |
|---|---|---|---|
whitespace |
string | "preserve" |
"strip" drops whitespace-only text between markup |
comments |
boolean | false | Report comment events |
instructions |
boolean | false | Report pi events |
max_depth |
integer | 256 | Open elements |
max_attrs |
integer | 256 | Attributes and declarations on one element |
max_namespaces |
integer | 1024 | Namespace declarations in scope at once, shadowed ones included |
max_tag_bytes |
integer | 1 MiB | One start tag, comment or processing instruction |
max_token_bytes |
integer | 1 MiB | Largest text chunk in one cursor event |
max_node_bytes |
integer | 8 MiB | Source bytes of one element built as a tree |
max_bytes |
integer | 0 | Whole document; 0 = unlimited |
Limits must be at least 1; max_bytes may be 0.
whitespace = "strip" suits data documents. It removes a run of text only when the whole run is literal whitespace between two pieces of markup (tags, comments, processing instructions) and no longer than max_token_bytes. Whitespace next to other text, inside CDATA, written as a character reference, or under xml:space="preserve" is kept.
Writer options
| Field | Type | Default | Description |
|---|---|---|---|
indent |
string | none | Spaces or tabs per level |
declaration |
boolean | false | Emit the XML declaration |
ns |
table | none | Preferred prefix -> URI bindings, declared on the root |
max_depth |
integer | 256 | Open elements |
max_attrs |
integer | 256 | Attributes and declarations on one element |
max_namespaces |
integer | 1024 | Namespace declarations in scope at once |
max_tag_bytes |
integer | 1 MiB | One start tag with its attributes, or one comment |
max_node_bytes |
integer | 8 MiB | Serialized size of one tree written by encode, w:node or w:element |
The limits mirror the reader's. Raise max_node_bytes to encode a larger tree in one call, or write it incrementally with start_element to avoid the per-tree max_node_bytes limit. reader:copy also streams without that per-tree limit. The depth, attribute, namespace and tag limits still apply to streamed output.
Indentation is meant for data documents. An element written as a whole tree is indented inside only when all of its children are elements. When writing incrementally, a line break precedes a child element or closing tag until text is written into the parent.
What Is Accepted
The parser is strict and fetches nothing:
- XML 1.0 with namespaces; one root element.
- A document type declaration is an error. Only
< > & ' "and character references are expanded. - Duplicate attributes, unbound prefixes and invalid characters are errors.
- Line endings are normalized to
\n. - Encodings: UTF-8, UTF-16, ISO-8859-1, windows-1252, US-ASCII. Malformed input is an error, never replaced.
HTML is not XML. Only well-formed XHTML parses.
Errors
| Condition | Kind | details.code |
|---|---|---|
| Wrong argument type or option | errors.INVALID |
argument |
| Malformed document | errors.INVALID |
syntax |
| Namespace error | errors.INVALID |
namespace |
| Unsupported or malformed encoding | errors.INVALID |
encoding |
| Limit exceeded | errors.INVALID |
limit |
| Element table outside the rules above | errors.INVALID |
element |
| Method not valid in the current state | errors.INVALID |
state |
| Stream read or write failed | errors.INTERNAL |
io |
| XML dispatcher queue is full | errors.UNAVAILABLE |
io |
| XML dispatcher has stopped | errors.UNAVAILABLE |
state |
| Operation canceled | errors.CANCELED |
canceled |
Parsing, limit and stream I/O failures are final: later operations on that reader, records or writer return the stored error, and the reader's accessors return nil. Calling a cursor method on the wrong event, such as node on a text event, is a state error that does not end the reader. Dispatcher admission errors reject a command before it executes; they are distinct from a parsing or stream I/O failure.
Parse errors carry the position and the open elements:
local doc, err = xml.decode(body)
if err then
local d = err:details()
-- d.code, d.line, d.column, d.offset, d.path; d.limit names the option for limit errors
return nil, err
end
See Error Handling for working with errors.