Skip to content
datahacks.dev
XML

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.

6 min read
Summarize in AI
Flat illustration of paired angle-bracket tags wrapped around a document, with the XML logo.

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

Cargo.tomlTOML
[dependencies]
quick-xml = { version = "0.37", features = ["serialize"] }
serde = { version = "1", features = ["derive"] }
src/main.rsRust
#[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.

Rust
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);
}
Text
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:

Rust
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());
Text
bad bool -> invalid type: string "maybe", expected a boolean

A 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:

Rust
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();
}
Text
stream: 200000 orders in 0.09s

200,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:

Text
mismatch -> ill-formed document: expected `</b>`, but `</a>` was found

On <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&amp;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.

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).

Also inTooling
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.