Parsing XML in Node With fast xml parser and Its Defaults
fast-xml-parser returns objects, but its defaults drop attributes and turn 007 into 7 and 29.50 into 29.5. Set the options, validate, and see the 4.4 s cost.

In Node, XML is rarely a first choice, and it turns up anyway: a SOAP shim, a feed, an SVG, a sitemap, an export from a partner. The library most people reach for is fast-xml-parser. It is pure JavaScript, has no native dependency and gives you plain objects. Its defaults are tuned for convenience and several of them change your data silently, so the first job is to set the options on purpose.
What the defaults do
const { XMLParser } = require("fast-xml-parser");
const r1 = new XMLParser().parse(xml);
console.log(JSON.stringify(r1.library.book[0]));{"title":"Cache Patterns","author":["Ana Lee","Bo Chen"],"price":29.5,"note":"Covers R&D"}Compare that with the file. The book has an id, an available attribute and a currency on the price, and none of them is in the output: **ignoreAttributes defaults to true**, so attributes are dropped. The price 29.50 is the number 29.5, because the parser converts values that look like numbers (parseTagValue defaults to true), and a number cannot keep a trailing zero, so the exactness of a decimal string is gone. The authors are an array here, because there are two.
More of the defaults in one line:
new XMLParser().parse("<a><z>007</z><big>12345678901234567890</big></a>"){"a":{"z":7,"big":"12345678901234567890"}}007 became the number 7: the leading zero of an identifier or a ZIP code is gone. The 20-digit number stayed a string, so the library avoids converting integers that would lose precision in a double, and that partial protection is not a design to rely on. In short, the default configuration is for reading simple, friendly XML into something quick to use, and it should not be the configuration for data where identifiers, money or attributes matter.
Setting the options
const p2 = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@_",
isArray: (name) => ["book", "author"].includes(name),
parseTagValue: false,
parseAttributeValue: false,
});
const r2 = p2.parse(xml).library;
console.log(JSON.stringify(r2.book[1]));{"title":"SQL Tuning, 2nd Edition","author":["Cy Ortiz"],"price":{"#text":"44.00","@_currency":"USD"},"@_id":"b2","@_available":"false"}Four options changed the picture. ignoreAttributes: false keeps attributes, with the prefix @_ you choose. parseTagValue: false and parseAttributeValue: false leave every value as the string in the file, so "44.00" stays exact and "false" stays the text (to be compared as text, not tested as truthy: the JavaScript string "false" is truthy). isArray is a function that says which element names are always lists: with book and author on it, the second book's single author is ["Cy Ortiz"], not "Cy Ortiz". Without that, a script that loops over book.author works for the book with two authors and iterates the characters of a string for the others.
The price shows the structure that appears whenever an element has both attributes and text: an object with the text under #text and the attributes with their prefix. The same element is a bare string when it has no attribute, so the same field can have two shapes in one file. If you need one shape, post-process into your own records:
const usd = r2.book.filter(b => b.price["@_currency"] === "USD")
.reduce((s, b) => s + Math.round(parseFloat(b.price["#text"]) * 100), 0);
console.log("USD cents", usd);USD cents 73507350 cents is 73.50, the same as every other tool in this cluster. Sums are done in integer cents: JavaScript has no decimal type, and 29.5 + 44 happens to be exact where longer sums of prices are not. This is the same advice as in the CSV and JSON posts about money.
Validation and errors
The parser itself is lenient about some malformed input. XMLValidator checks syntax first and returns a description:
console.log("valid:", XMLValidator.validate("<a><b></a>"));valid: {
err: {
code: 'InvalidTag',
msg: "Expected closing tag 'b' (opened in line 1, col 4) instead of closing tag 'a'.",
line: 1,
col: 7
}
}The function returns true for a good document and an object with an err for a bad one, with a code, a message, a line and a column. Call it before parse for untrusted input, or pass parse(xml, true) to validate during parsing in versions that support it. This is only a well-formedness check; it has no schema.
Entities and safety
The external entity file made the parser throw External entities are not supported, so a file read through an entity does not work. The library also has options about entity expansion and limits (for processing entities defined in the document, with caps on the count and size of expansions), which exist because entity expansion was a denial-of-service route in earlier versions of JavaScript XML parsers. Keep them at their defaults and check your version's release notes. The other hazards of building objects from element names are real in JavaScript: a tag called __proto__ or constructor must not pollute object prototypes. I did not test whether the version used here guards against it, so for untrusted input add a check that rejects such names before the data goes anywhere (XML security problems to close before production).
A loader that is hard to misuse
Wrap the parser in a function that returns your own records and nothing else, so no other code depends on the shape of the generic object:
function readLibrary(xml) {
const res = XMLValidator.validate(xml);
if (res !== true) throw new Error(res.err.msg);
const lib = p2.parse(xml).library;
return lib.book.map(b => ({ id: b["@_id"], title: b.title, authors: b.author, cents: Math.round(parseFloat(b.price["#text"]) * 100) }));
}That is a sketch of the pattern, and I ran its pieces separately above. The validation, the parse options and the conversions live in one function with one test.
Size
The library builds one object for the whole document. For the 36.6 MB file:
orders 200000 in 4.39 s, rss MB 6954.4 seconds and 695 MB of resident memory for the 200,000 orders, roughly 19 times the file. That is within the range of tree parsers in other languages and about ten times slower than the streaming Rust and Java readers on the same file (0.09 and 0.3 seconds). For files in the hundreds of megabytes, a streaming parser is the right design: the sax package, or the saxes parser, or an XML pull library, processing records as they close. fast-xml-parser is not a streaming parser; its builder side (XMLBuilder) does the reverse and writes objects as XML, with the same attention to options (ignoreAttributes, the attribute prefix, escaping).
Related reading
For the browser equivalent see parsing XML in JavaScript with DOMParser, and for the language overview parsing XML in Java, C# and other typed languages. The library is documented in the fast-xml-parser repository and the number precision issue in the MDN note on Number.MAX_SAFE_INTEGER. Test an XPath in the XPath tester.
When fast-xml-parser is the wrong choice in Node
You need XPath, namespaces or schemas. The object model has no XPath, and namespace prefixes stay in the keys. Use a libxml2 binding or an xmldom package with an XPath library; I did not test one here.
The file is large. Use a streaming parser.
Exact types matter. If the data has identifiers, money or flags, set parseTagValue and parseAttributeValue to false and convert fields by name.
You are in the browser. Use DOMParser, which is built in.


