Contents
Getting started
- Install — Cargo, source
- Quick Start — parse and query in ten lines
The oxml ecosystem (library + five satellite crates)
- The oxml ecosystem —
oxml,oxml-cli,oxml-lsp,oxml-mcp,oxml-wasm,xmlschemaat a glance
Library reference
- One-minute migration — name-for-name mapping from
sxd-xpath,roxmltree,quick-xml,libxml - Why this approach? — design rationale
- Capabilities in 0.0.3 — release inventory
- Ecosystem comparison — what each crate does and does not do
- Benchmarks — measured, with the method stated
- Features — cargo feature flags
Behaviour worth knowing
- Attributes are nodes — why
@langreturns what you expect - Namespaces resolve by URI — the element/attribute asymmetry
- Number formatting — why
sum()prints17.49 - Entity expansion is not supported — and that is the point
Practical
- Library usage — the full surface
- Examples — runnable programs
- When not to use oxml — honestly
- Development — building, testing, benchmarking
- Security — threat model
- Documentation
- Acknowledgements
- License
Install
[]
= "0.0.3"
Parsing only, without the XPath engine:
[]
= { = "0.0.3", = false, = ["std"] }
From source:
Minimum supported Rust version: 1.86.0. Raising it is a breaking change and appears in the changelog.
Quick Start
use ;
let doc = parse.unwrap;
let titles = compile.unwrap;
assert_eq!;
XPath is optional. The tree stands on its own:
use parse;
let doc = parse.unwrap;
let root = doc.root_element.unwrap;
let b = doc.children;
assert_eq!;
assert_eq!;
The oxml ecosystem
Every member ships the same version number. If the core is at
0.0.X then so is every satellite, so there is never a compatibility
table to consult. Versions advance in 0.0.1 steps along the 0.0.x
line; 0.1.0 follows 0.0.999.
| Crate | What it is | Status |
|---|---|---|
oxml |
Core library — parser, tree, XPath 1.0 | Available |
oxml-cli |
Command-line querying and formatting | Planned |
oxml-lsp |
Language server for XML documents | Planned |
oxml-mcp |
Model Context Protocol server | Planned |
oxml-wasm |
WebAssembly bindings | Planned |
xmlschema |
XSD validation | Planned |
xmlschema keeps its existing published name rather than being
repurposed into the core: the name means XSD validation, and that is
what it will be.
One-minute migration
From sxd-xpath
sxd-xpath has not shipped a release since 2018. The concepts map
directly.
sxd-xpath |
oxml |
|---|---|
sxd_document::parser::parse(s) |
oxml::parse(s) |
sxd_xpath::evaluate_xpath(&doc, "//x") |
XPath::compile("//x")?.evaluate(&doc) |
Value::Nodeset(set) |
Value::NodeSet(Vec<NodeId>) |
value.string() |
value.to_str(&doc) |
value.number() |
value.to_number(&doc) |
value.boolean() |
value.to_boolean() |
The one structural difference: oxml separates compiling an expression from evaluating it, so a query used against many documents is parsed once.
From roxmltree
roxmltree |
oxml |
|---|---|
Document::parse(s) |
oxml::parse(s) |
doc.root_element() |
doc.root_element() |
node.children() |
doc.children(id) |
node.attribute("k") |
doc.attribute(id, "k") |
node.text() |
doc.text(id) |
| — | XPath::compile(..) |
roxmltree returns node objects that borrow the document; oxml
returns NodeId handles and takes the document as a parameter. That
trade is what allows a node to know its parent without a lifetime
cycle.
From quick-xml
quick-xml is a streaming reader and writer, not a tree. If you are
matching on Event::Start and maintaining your own stack to find
elements, that is the loop oxml replaces:
let xml = "<list><item id='7'>found</item><item id='8'/></list>";
// quick-xml: match on Event::Start, maintain your own stack, track
// depth, and remember which element you are inside.
//
// oxml: say what you want.
let doc = parse.unwrap;
let hits = compile.unwrap.evaluate;
assert_eq!;
Keep quick-xml for gigabyte streams you never want fully in memory.
From libxml
libxml binds libxml2 through C-FFI. Migrating to oxml removes the C
toolchain, the unsafe blocks, and the libxml2 CVE stream — at the
cost of XSLT, which oxml does not yet have.
Why this approach?
Rust's XML ecosystem is strong at one end and empty at the other.
Parsing is a solved problem: quick-xml has 379 million downloads and
is genuinely fast. What nothing maintained provides is the other half
of what lxml gives Python — querying.
The only XPath implementation on crates.io, sxd-xpath, last shipped
in 2018. XSLT and XSD validation have no pure-Rust implementation
at all. So a Rust project that needs to ask questions of an XML
document has three options: bind libxml2 through C-FFI, depend on a
crate abandoned seven years ago, or hand-roll traversal.
oxml closes the query gap first, because that is the one people hit.
Two architectural choices motivate the design:
-
An arena, not a pointer graph. Nodes live in a
Vecand are addressed by index. That is what lets every node know its parent withoutRc,RefCell, orunsafe— the parent link is an index, so there is no ownership cycle for the borrow checker to reject. The cost is that aNodeIdis only meaningful against the document that issued it; accessors returnNonerather than panicking when it is not. -
#![forbid(unsafe_code)], enforced twice. The attribute fails the build, and CI additionally greps for it — because the change that silently drops the guarantee is deleting the attribute, and a compile-time check cannot catch its own removal. Most XML crates with XPath support reach it through C-FFI; thoseunsafeblocks are usually well-vetted, but their existence makes a security-conscious downstream audit meaningfully harder.
Capabilities in 0.0.3
Parsing
- Elements, attributes, text, comments, processing instructions, CDATA
- XML declaration and
DOCTYPEskipping, including bracketed internal subsets containing> - Namespace resolution by URI, with the
xml:prefix bound implicitly - The five predefined entities and numeric character references
- Adjacent character data merged, so a caller never sees two text siblings in a row
Tree
- Arena-backed, index-addressed nodes
- Parent, children, descendants, text (XPath
string-valuesemantics) - Attributes as first-class nodes
XPath 1.0
- Ten axes:
child,descendant,descendant-or-self,parent,ancestor,ancestor-or-self,self,attribute,following-sibling,preceding-sibling - Abbreviations:
//,.,..,@ - Node tests: name,
*,text(),comment(),node() - Predicates, including positional (
[1]) and existential comparison - All four value types with the specified conversions
- 25 functions:
count,sum,position,last,string,number,boolean,not,true,false,concat,contains,starts-with,substring,string-length,normalize-space,local-name,namespace-uri,floor,ceiling,round, and arithmetic, comparison, boolean and union operators
Not yet: serialisation, XSD validation, XSLT, XPath 2.0+.
Ecosystem comparison
| Crate | Downloads | Last release | Parse | Tree | XPath | XSLT | XSD | unsafe |
|---|---|---|---|---|---|---|---|---|
oxml |
— | active | ✅ | ✅ | ✅ | ✗ | ✗ | none |
quick-xml |
379M | active | ✅ | ✗ | ✗ | ✗ | ✗ | some |
xml-rs |
137M | active | ✅ | ✗ | ✗ | ✗ | ✗ | none |
roxmltree |
66M | active | ✅ | ✅ | ✗ | ✗ | ✗ | none |
xmltree |
19M | active | ✅ | ✅ | ✗ | ✗ | ✗ | none |
sxd-xpath |
2M | 2018 | ✅ | ✅ | ✅ | ✗ | ✗ | none |
libxml |
2M | active | ✅ | ✅ | ✅ | ✅ | ✅ | C-FFI |
xot |
218K | 2025 | ✅ | ✅ | ✗ | ✗ | ✗ | none |
Download figures from crates.io, August 2026.
Benchmarks
Run them yourself — the numbers below are from one machine and are useful as ratios, not absolutes:
| Benchmark | Time | What it measures |
|---|---|---|
parse/wide_1000 |
489 µs | 1,000 sibling elements — sibling handling |
parse/deep_500 |
109 µs | 500 nesting levels — recursion |
parse/attributes_1000 |
823 µs | 4 attributes each, namespaced — resolution |
xpath/compile |
889 ns | Compiling //book[@lang='en']/title |
xpath/eval_descendant |
245 µs | //title over 2,000 books |
xpath/eval_predicate |
1.19 ms | //book[@lang='en']/title over 2,000 books |
Compilation and evaluation are timed separately on purpose: a caller that compiles once and evaluates many times pays only the second, and reporting a combined figure would hide that.
The benchmarks have already earned their place. The first
implementation deduplicated each step's results with a linear
contains scan, which is O(n²). //title over 2,000 elements took
10.8 ms; sorting instead brought it to 0.49 ms — a 22×
improvement with identical results. The benchmark was written before
the optimisation, not after it.
Features
| Flag | Default | What it does |
|---|---|---|
std |
✅ | Standard library integration, including std::error::Error. |
xpath |
✅ | The XPath 1.0 engine. Disable if you only need to parse. |
Both are additive. With neither, the crate is no_std and provides the
parser and tree over alloc.
Attributes are nodes
XPath models attributes as nodes that are reachable from the
attribute:: axis but are not children of their element. oxml does
the same: attribute nodes live in the arena, know their parent, and are
absent from children().
use ;
let doc = parse.unwrap;
// The attribute's value, not the element's text.
let lang = compile.unwrap;
assert_eq!;
// And `child::` does not see it.
let root = doc.root_element.unwrap;
assert_eq!; // just <title>
The first implementation returned the owning element from the
attribute axis, which made string(//book/@lang) evaluate to the
book's text. It was wrong, and silently so — which is why the test that
pins this behaviour names the bug it prevents.
Namespaces resolve by URI
Two prefixes bound to the same URI name the same thing:
use parse;
let doc = parse.unwrap;
let root = doc.root_element.unwrap;
let kids = doc.children;
assert_eq!;
An unprefixed element takes the default namespace. An unprefixed attribute is in no namespace at all — not its element's. That asymmetry is the classic source of namespace bugs, so it is an explicit parameter in the parser rather than an assumption.
Number formatting
XPath has one numeric type: IEEE 754 double. sum() over 9.99 and
7.50 produces the float nearest 17.490000000000002, and printing
every digit needed to distinguish that value is what the
specification's wording literally asks for.
No other engine does that. libxml2, Xalan and Saxon all print 17.49,
because 15 significant digits is the point past which IEEE 754 noise
starts showing. oxml matches them:
use ;
let doc = parse.unwrap;
let sum = compile.unwrap;
assert_eq!;
Matching the ecosystem matters more here than matching the letter of a sentence written before shortest-round-trip float printing existed.
Entity expansion is not supported
Only the five predefined entities (< > & '
") and numeric character references are resolved. External and
custom entities are rejected, not expanded:
let src = r#"<!DOCTYPE a [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><a>&xxe;</a>"#;
assert!;
This forecloses XXE and billion-laughs by construction. There is no flag to enable expansion, so there is no way to configure the vulnerability back in — which is the difference between a parser that is safe and one that is safe if you remember to set the option.
Library usage
use ;
let doc = parse.unwrap;
// Navigation
let root = doc.root_element.unwrap;
let first = doc.children;
assert_eq!;
assert_eq!;
// Attributes
assert_eq!;
for attr in doc.attributes
// Node kinds
assert!;
// Text, with XPath string-value semantics: comments contribute nothing
assert_eq!;
// Queries, compiled once
let q = compile.unwrap;
assert_eq!;
Examples
| Example | What it shows |
|---|---|
parse_and_query |
XPath queries and direct tree traversal side by side |
When not to use oxml
Honestly:
- You need XSLT. oxml does not have it. Use
libxml. - You need XSD validation today.
xmlschemais planned, not shipped. - You are streaming gigabytes. oxml builds a full tree in memory.
quick-xmlis the right tool and will stay so. - You need XPath 2.0 or 3.1. oxml implements 1.0.
- Raw parse throughput is your only metric.
quick-xmlis extremely well optimised and years ahead on that axis. oxml's argument is what you can do after parsing.
Development
CI runs all of the above on Linux, macOS and Windows, plus an MSRV
build and a check that #![forbid(unsafe_code)] is still present.
Security
See SECURITY.md for reporting and the full threat model.
In summary: entity expansion is not implemented, so XXE and
billion-laughs are foreclosed; #![forbid(unsafe_code)] rules out
memory-corruption bugs; and deeply nested documents are parsed
recursively, so untrusted input of unbounded depth should be parsed on
a thread with a known stack size.
Documentation
Acknowledgements
oxml exists because of work that came before it:
- lxml — the reference for what an XML toolkit should offer.
- libxml2 — decades of hard-won correctness, and the yardstick for behaviour.
sxd-xpath— the first XPath implementation in Rust.quick-xmlandroxmltree— proof that Rust XML parsing can be both fast and safe.- W3C — for the XPath 1.0 specification, which this implementation follows.
License
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you shall be dual licensed as above, without any additional terms or conditions.