Parsing XML in Rust With quick xml and serde Structs
Read XML into Rust structs with quick-xml and serde, stream 200,000 orders in 0.09 seconds, and see how bad booleans and mismatched tags are reported.

Rust's XML story is a handful of crates and no standard library support. The one I reach for is quick-xml: it is fast, it reads from files, memory or any buffered reader, and with its serialize feature it plugs into serde, so XML maps to structs the way JSON does. It is also low-level in places, which is the price of the speed.
Reading into structs
[dependencies]
quick-xml = { version = "0.37", features = ["serialize"] }
serde = { version = "1", features = ["derive"] }#[derive(Debug, Deserialize)]
struct Library {
#[serde(rename = "book")]
books: Vec<Book>,
}
#[derive(Debug, Deserialize)]
struct Book {
#[serde(rename = "@id")]
id: String,
#[serde(rename = "@available")]
available: bool,
title: String,
#[serde(rename = "author", default)]
authors: Vec<String>,
price: Price,
note: Option<String>,
}
#[derive(Debug, Deserialize)]
struct Price {
#[serde(rename = "@currency")]
currency: String,
#[serde(rename = "$text")]
amount: String,
}The mapping uses serde's rename as a mini-language. A name starting with @ is an attribute (@id). The special name $text is the element's text, which is how <price currency="USD">29.50</price> becomes a struct with a currency and an amount. A Vec with rename = "author" collects the repeated child elements, and default makes it an empty vector when there are none. Option marks a field that may be absent.
let xml = std::fs::read_to_string("../library.xml").unwrap();
let lib: Library = quick_xml::de::from_str(&xml).unwrap();
for b in &lib.books {
println!("{} | {} | {:?} | {} {} | available={} | note={:?}", b.id, b.title, b.authors, b.price.amount, b.price.currency, b.available, b.note);
}b1 | Cache Patterns | ["Ana Lee", "Bo Chen"] | 29.50 USD | available=true | note=Some("Covers R&D")
b2 | SQL Tuning, 2nd Edition | ["Cy Ortiz"] | 44.00 USD | available=false | note=None
b3 | Indexes "Explained" | ["Di Park"] | 250000 VND | available=true | note=Some("")The structure came through as typed data. The note shows the usual distinction: None for the absent element and Some("") for the empty one. The amount is kept as text, because Rust's standard library has no decimal; the rust_decimal crate (which I did not test here) parses text to an exact decimal and implements serde, so you can use it as the field type. The ampersand arrived decoded.
Errors are values
Rust makes you handle the failure, and the messages are specific:
let bad: Result<Library, _> = quick_xml::de::from_str(r#"<library><book id="x" available="maybe"><title>t</title><price currency="USD">1</price></book></library>"#);
println!("bad bool -> {}", bad.err().map(|e| e.to_string()).unwrap_or_default());bad bool -> invalid type: string "maybe", expected a booleanA boolean that is not a boolean is an error, not a false. A required field that is missing (title removed) also errors, naming the field. Unknown elements are ignored by default, which you can change with serde's deny_unknown_fields on a struct. That attribute is a good choice for formats you control and a bad one for feeds that evolve. The contrast with the silent defaults of some other stacks is one reason I like binding in Rust: you decide what leniency to allow, and the default is to refuse.
Streaming events
The reader API is the fast path, and the one for large files:
let mut reader = Reader::from_file("../orders_nested.xml").unwrap();
let mut buf = Vec::new();
let mut orders = 0u64;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) if e.name().as_ref() == b"order" => orders += 1,
Ok(Event::Eof) => break,
Err(e) => panic!("{}", e),
_ => {}
}
buf.clear();
}stream: 200000 orders in 0.09s200,000 orders from a 36.6 MB file in 0.09 seconds in a release build, which is roughly nine times faster than the Go Decoder loop (0.82 seconds) and three times faster than the Java StAX run (0.32 seconds, including JVM start-up) on the same file. The speed comes from reusing one buffer (buf.clear() each time round) and from handing out borrowed byte slices instead of allocating strings. A debug build is much slower, which is a common surprise: measure with --release. Names are byte slices (b"order"), so comparisons are on bytes, and a namespace prefix is part of the name unless you use the namespace-aware reader (NsReader).
For typed records from a stream, read events until you reach the start of a record, then deserialize that element with quick_xml::de::Deserializer from the reader; the details differ between versions, so follow the crate's documentation for the one you use. I did not run that combination here.
Malformed input is an event error:
mismatch -> ill-formed document: expected `</b>`, but `</a>` was foundOn <a><b></a> the reader returned an error naming both tags. Note that checking that end tags match is a setting (check_end_names, on by default in recent versions), so a reader configured loosely will accept the document.
Writing
The same derives work for output with quick_xml::se::to_string(&value): the @ and $text renames write attributes and text, and the element names come from the field names or rename. The output is compact, with no indentation unless you use the serializer with indentation settings, and text is escaped (R&D). A caution carried over from other languages: an Option that is None is omitted, so absent and empty round-trip as distinct, and a Vec writes repeated elements. As with every binding, keep a golden file of the output in a test and compare it, so a renamed field is caught before the partner finds it (serializing C# objects to XML makes the same argument for .NET).
Security and limits
quick-xml has no support for DTDs and does not resolve external entities, so the file-read attack has nothing to work with; entity references that are not the five predefined ones or character references are reported as errors by the unescape step. It also has no limit on depth, text size or element count. A deeply nested document consumes stack only if your code recurses, and a streaming loop does not. For untrusted input, cap the bytes before reading (Read::take) and cap the depth in your event loop with a counter that increments on Start and decrements on End. The cluster overview is XML security problems to close before production.
Choosing between the two modes
The serde route is the one to use when the document is small or the records are independent and you want types. The event route is for speed, for huge files, and for formats that do not map onto structs. In practice I start with serde on a sample, and move to events only when a measurement says I must.
Related reading
For the language comparison see parsing XML in Java, C# and other typed languages, and for the same streaming idea elsewhere parsing XML in Go with encoding/xml. The crate is documented on docs.rs for quick-xml and serde at serde.rs. Test an XPath in the XPath tester.
When quick-xml is more than you need
The file is small and you want a tree. A DOM-style crate such as roxmltree (read-only tree) is simpler for queries; I did not test it here.
You need XPath or XSLT. Neither is in quick-xml. Bind to libxml2 or run a tool.
The format is large and namespaced. Generated bindings from an XSD may save time over hand-written serde structs, if a generator exists for your target.
You just need a count or a field. An external tool (xmllint, xmlstarlet) is faster to write, and a Rust program is for the job that repeats (handy command-line tools for XML).


