APIs: Rust¶
Rust's XML story is different in kind from Java's or
.NET's. There is no single batteries-included System.Xml;
instead there is a set of focused crates, and the ecosystem is streaming-first
and parsing-first. Full XSLT 2.0/3.0 and schema-aware
processing are not available in pure Rust — for those you reach across an FFI
boundary to libxml2/libxslt or to Saxon-C.
This page works the five tasks on the shared
invoice.xml, and is honest about which
ones Rust does natively.
This site's own tooling is Rust
The unxml renderings shown on every
page in this section are produced by a Rust program built on quick-xml —
the same crate in task 1 below. The flattened pseudocode you have been reading
is a streaming pull-parser walking the document and re-emitting it. Real-world
Rust XML, in other words, is exactly this page.
| Crate | Role |
|---|---|
quick-xml |
fast streaming pull reader + writer; serde support |
roxmltree |
read-only tree, namespace-aware navigation |
sxd-document + sxd-xpath |
pure-Rust DOM + XPath 1.0 |
libxml |
bindings to libxml2: tree, XPath, XSD validation |
serde + quick-xml::de |
data binding to structs |
1. Parse — quick-xml (streaming pull)¶
The idiomatic Rust reader is a forward-only pull parser at constant memory. Use the namespace-resolving variant so you match on URIs, not prefixes:
use quick_xml::events::Event;
use quick_xml::name::ResolveResult::Bound;
use quick_xml::reader::Reader;
let mut reader = Reader::from_file("invoice.xml")?;
let mut buf = Vec::new();
loop {
match reader.read_resolved_event_into(&mut buf)? { // (1)!
(Bound(ns), Event::Start(e))
if ns.as_ref() == b"urn:example:invoice" // (2)!
&& e.local_name().as_ref() == b"total" =>
{
for attr in e.attributes().flatten() {
if attr.key.as_ref() == b"currency" {
println!("currency = {}", attr.unescape_value()?);
}
}
}
(_, Event::Eof) => break,
_ => {}
}
buf.clear(); // (3)!
}
read_resolved_event_intoreturns a(ResolveResult, Event)pair — the parser has already resolved the element's prefix to a namespace URI for you.- So the match condition compares the URI (
urn:example:invoice) and the local name (total) — never the prefixedinv:total. This is the namespace rule made unavoidable: inquick-xmlthe prefix is just bytes you were handed, the URI is the identity. - Reusing and clearing
bufis why this stays allocation-light — the engine behindquick-xml's speed, and behindunxml.
2. Navigate — a tree, two ways¶
roxmltree — read-only, namespace-aware¶
When you want random access rather than a stream, roxmltree parses into a
borrowed tree and exposes namespaces directly:
let text = std::fs::read_to_string("invoice.xml")?;
let doc = roxmltree::Document::parse(&text)?;
let total = doc.descendants()
.find(|n| n.tag_name().namespace() == Some("urn:example:invoice") // (1)!
&& n.tag_name().name() == "total")
.unwrap();
println!("{}", total.text().unwrap()); // 100.00
println!("{:?}", total.attribute("currency")); // Some("EUR")
tag_name()is a(namespace, local-name)pair — you filter on the URI, the same discipline as the streaming reader.roxmltreeis fast and read-only by design; for mutation you would uselibxml's tree or build output withquick-xml's writer.
sxd-xpath — actual XPath (1.0)¶
If you want real XPath expressions in pure Rust, sxd-xpath
provides them — with a namespace-prefix map, just like everywhere else:
use sxd_document::parser;
use sxd_xpath::{Context, Factory};
let package = parser::parse(&text)?;
let document = package.as_document();
let xpath = Factory::new().build("/i:invoice/i:total")?.unwrap();
let mut ctx = Context::new();
ctx.set_namespace("i", "urn:example:invoice"); // (1)!
let value = xpath.evaluate(&ctx, document.root())?;
println!("{}", value.into_string()); // 100.00
iis our prefix bound to the document's URI — the file'sinv:is irrelevant, as on every other runtime. Note the ceiling:sxd-xpath(and libxml2) implement XPath 1.0 only. There is no pure-Rust XPath 2.0/3.1.
3. Validate against XSD — via libxml (libxml2)¶
Pure Rust has no XSD validator. The practical route is the libxml crate, which
binds the same libxml2 that backs Python's lxml:
use libxml::parser::Parser;
use libxml::schemas::{SchemaParserContext, SchemaValidationContext};
let doc = Parser::default().parse_file("invoice.xml")?;
let mut schema_parser = SchemaParserContext::from_file("invoice.xsd");
let mut validator = SchemaValidationContext::from_parser(&mut schema_parser)
.expect("schema compiled");
match validator.validate_document(&doc) {
Ok(()) => println!("valid"),
Err(errors) => for e in errors { // (1)!
println!("{}", e.message.unwrap_or_default());
}
}
- Because this is libxml2 under the hood, it resolves
xs:import/xs:includeand reports the same diagnostics you would get from lxml — it is the first layer of the validation pipeline, reached through an FFI binding rather than a native implementation.
4. Transform — XSLT is not native¶
This is the honest gap. There is no maintained pure-Rust XSLT engine, for any version. Two real options:
- libxslt via FFI — XSLT 1.0 only (libxslt never implemented 2.0+). Usable
from the
libxml/libxsltC bindings if 1.0 is enough. - Saxon-C / SaxonCHE — to run the modern XSLT 3.0 this site teaches, call Saxon's native library over its C API from Rust via FFI (or shell out to the Saxon CLI). Recall from the Java page that SaxonCHE is the Java engine compiled to native code with GraalVM — so a Rust program linking it is really driving Saxon-HE, HE limitations and all (no streaming, no schema-aware transforms).
Rust program ──FFI──▶ SaxonCHE (native-compiled Java) ──▶ XSLT 3.0 result
└─FFI──▶ libxslt (C) ──▶ XSLT 1.0 result
For many Rust services the pragmatic answer is to not transform in Rust at all — do the XSLT step in a Java/Python sidecar, and keep Rust for the high-throughput parse/validate/route work it excels at.
5. Data binding — serde + quick-xml¶
Rust's binding story is serde, via quick-xml's deserializer. Attributes use a
@ prefix and element text uses $text:
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Invoice {
id: String,
total: Total,
}
#[derive(Debug, Deserialize)]
struct Total {
#[serde(rename = "@currency")]
currency: String,
#[serde(rename = "$text")]
amount: String,
}
let inv: Invoice = quick_xml::de::from_str(&text)?; // (1)!
println!("{} {}", inv.total.amount, inv.total.currency);
- Convenient, but with a real caveat that ties back to this whole section:
serdematching keys on local names is awkward with namespaces. Unlike JAXB or .NET'sXmlSerializer, there is no first-class "this field is in namespace U" annotation — multi-namespace or extension-heavy documents push you back to thequick-xml/roxmltreeelement APIs. Binding shines for single-namespace, schema-stable documents.
Rust cheat-sheet¶
| Task | Crate / approach |
|---|---|
| Streaming parse | quick-xml (read_resolved_event_into) |
| Tree navigate | roxmltree (read-only, ns-aware) |
| XPath 1.0 | sxd-xpath (prefix map) or libxml |
| XPath 2.0/3.1 | none (not available in Rust) |
| Validate XSD | libxml (libxml2 binding) |
| XSLT 1.0 | libxslt via FFI |
| XSLT ⅔ | Saxon-C / SaxonCHE via FFI (no native) |
| Binding | serde + quick-xml::de (weak on namespaces) |
The shape of Rust XML
Rust gives you the fastest, lowest-memory parse and write of any runtime
here, plus XPath 1.0 and XSD through libxml2 — but the declarative upper
layers (XSLT ⅔, schema-aware processing) live behind FFI. The takeaway: use
Rust for ingest, validation and routing at volume; cross to Saxon for
transformation. That is the same division of labour unxml itself follows —
a fast Rust front end over the XML, nothing more.