xml2js
xml2js converts an XML string into a plain JavaScript object and back again. Call parseStringPromise(xml) and you get nested objects where element attributes land under a $ key, text content lands under _, and repeated child elements become arrays. A Builder class does the reverse, turning an object back into an XML document. It is a thin layer over two older packages: sax-js does the actual tokenizing and xmlbuilder-js does the serializing. There is no DOM, no XPath, and no schema validation; the whole idea is that you get an ordinary object you can destructure. It is the incumbent XML parser in Node, sitting underneath SOAP clients and a long tail of vendor integrations, which is most of why it still gets tens of millions of downloads a week.
It works, it is everywhere, and if it is already in your dependency tree there is no urgent reason to rip it out. For anything new, fast-xml-parser does the same job faster, with types, and with a maintainer who still ships releases.
Use it if
- You are consuming XML from something you do not control (SOAP endpoints, RSS and Atom feeds, sitemaps, carrier and payment vendor APIs) and you just want the values as an object
- You need to emit XML too, and want parse and build in the same package so round-tripping a vendor document keeps the same key conventions
- The document shape is irregular and you want knobs rather than a schema: renaming the attribute and text keys, stripping namespace prefixes, coercing numbers, trimming whitespace, all through options
- You are working in an existing codebase or with a library like node-soap that already returns xml2js-shaped objects, and matching that shape is worth more than raw speed
- You want strict well-formedness enforced by default, so malformed markup fails loudly instead of being guessed at
- This is new code and you have a choice. fast-xml-parser is significantly faster, ships its own TypeScript types, publishes ESM and CJS, and has releases from this decade. There is no capability here that justifies picking xml2js fresh in 2026
- The default output shape will hurt you. With explicitArray on (the default) a single child still comes back as an array, so real code reads result.root.item[0].name[0]. Turn it off and single versus multiple children become ambiguous instead, so a list that happens to have one element parses as an object and your .map() throws in production
- Your documents are large or streaming. Everything is buffered into one object graph, so a 200MB feed is a 200MB-plus heap allocation. sax or saxes gives you an event stream and constant memory
- You care about the maintenance signal. The last npm release, 0.6.2, was July 2023; there are 205 open issues (248 counting PRs) against a single maintainer whose README asks you to email him rather than open issues; and the source is CoffeeScript with compiled JavaScript checked in, so contributing a fix means a toolchain nobody has
- You need types. Nothing is bundled, so you install @types/xml2js from DefinitelyTyped and the parse result still comes back as any, meaning the awkward output shape gets zero help from the compiler
- You are shipping to a browser. It is roughly 96KB minified because xmlbuilder comes along whether or not you ever build a document
- You are pinned below 0.5.0. Advisory GHSA-776f-qx25-q3cc covers a prototype pollution issue in every version before that, so audit tooling will keep flagging you until you upgrade
Setup reality
npm install xml2js pulls two dependencies, sax and xmlbuilder, and no native code. It is a CommonJS package with no exports map and no type field, though named ESM imports do resolve through Node's interop, so import { parseStringPromise } from 'xml2js' works. Types are not included; add @types/xml2js separately and expect the result to be typed any. The engines field claims Node 4, which tells you how old the packaging is. The defaults are the real setup work: explicitArray is true so every child is an array, attributes sit under $, text sits under _, and trim is false so whitespace in your source document ends up in your values. Parse one document per Parser instance, or call reset() between documents; sharing a Parser across files is explicitly not guaranteed to work. If you set emptyTag to an object literal, every empty element in the document shares that one reference and mutating one mutates all, so pass a factory like () => ({}) instead. Callbacks run synchronously unless you set async: true, which the README warns may flip in a future version.
Patterns
Parse an XML string into an objectparse-with-promise
const { parseStringPromise } = require('xml2js')
const xml = '<root id="7"><item n="1">a</item><item n="2">b</item></root>'
const result = await parseStringPromise(xml)
// { root: { '$': { id: '7' },
// item: [ { _: 'a', '$': { n: '1' } },
// { _: 'b', '$': { n: '2' } } ] } }
console.log(result.root.item[0]._) // 'a'
console.log(result.root.$.id) // '7'Attributes live under $ and text under _, and every value is a string even when it looks like a number. There is also a callback form, parseString(xml, cb), whose callback runs synchronously unless you pass async: true.
Stop wrapping every child in an arrayflatten-the-output
const result = await parseStringPromise(xml, {
explicitArray: false,
mergeAttrs: true,
})
// { root: { id: '7',
// item: [ { _: 'a', n: '1' }, { _: 'b', n: '2' } ] } }explicitArray: false gives an array only when there really are several children, which is friendlier to read and a genuine footgun: a list with exactly one entry parses as a bare object, so guard with Array.isArray or wrap it with [].concat before iterating.
Drop soap: and ns: prefixes from tag namesstrip-namespace-prefixes
const { parseStringPromise, processors } = require('xml2js')
const result = await parseStringPromise(soapXml, {
tagNameProcessors: [processors.stripPrefix],
explicitArray: false,
})
// <soap:Envelope><soap:Body><ns:Result>ok</ns:Result>...
// becomes { Envelope: { Body: { Result: 'ok' } } }stripPrefix rewrites tag names only, so xmlns declarations remain in the $ attribute bag and two different namespaces that share a local name will collide into one key. Use attrNameProcessors for the same treatment on attributes.
Parse numbers and booleans instead of stringscoerce-values
const { parseStringPromise, processors } = require('xml2js')
const result = await parseStringPromise(xml, {
explicitArray: false,
valueProcessors: [processors.parseNumbers, processors.parseBooleans],
attrValueProcessors: [processors.parseNumbers],
})
// <count>42</count> -> count: 42Processors run on every value in the document, so a zip code like 02134 becomes 2134 and a version string like 1.10 becomes 1.1. Write a custom processor keyed on the element name if only some fields should be coerced.
Catch malformed XMLhandle-parse-errors
try {
await parseStringPromise('<a><b></a>')
} catch (err) {
// Error: Unexpected close tag\nLine: 0\nColumn: 10\nChar: >
console.error(err.message.split('\n')[0])
}Strict mode is on by default and the error carries line, column, and character offsets from sax, which is worth surfacing in logs. Setting strict: false makes it accept HTML-ish input and, as the README puts it, yield just about anything.
Parse many files without cross-contaminationone-parser-per-document
const { Parser } = require('xml2js')
async function parseAll(files) {
const out = []
for (const file of files) {
const parser = new Parser({ explicitArray: false })
out.push(await parser.parseStringPromise(await fs.readFile(file)))
}
return out
}The docs recommend a fresh Parser per document; reusing one is only guaranteed if you call reset() first, and doing neither can leak state between documents. Constructing a Parser is cheap, so just make a new one.
Control what an empty element becomesempty-tag-default
await parseStringPromise('<a><b/></a>', { explicitArray: false })
// { a: { b: '' } }
await parseStringPromise('<a><b/></a>', {
explicitArray: false,
emptyTag: () => ({}),
})
// { a: { b: {} } }Empty elements default to an empty string, which breaks code that expects an object. Pass a factory rather than a literal: emptyTag: {} would hand the same object to every empty element in the document, so mutating one changes them all.
Keep namespace URIs alongside local namesnamespace-metadata
const result = await parseStringPromise(xml, {
explicitArray: false,
xmlns: true,
})
// { r: { '$ns': { uri: '', local: 'r' },
// x: { _: '1', '$ns': { uri: '', local: 'x' } } } }Every element gains a $ns field with its resolved URI and local name, which is the only way to tell two same-named elements from different namespaces apart. It also adds a key to every node, so anything iterating Object.keys has to skip it.
Turn an object back into an XML documentbuild-xml
const { Builder } = require('xml2js')
const builder = new Builder()
const xml = builder.buildObject({ name: 'Super', age: 23 })
// <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
// <root>
// <name>Super</name>
// <age>23</age>
// </root>With a single top-level key that key becomes the root element; with several keys they are all wrapped in <root>. Round-tripping is only guaranteed for the default options apart from attrkey, charkey, and explicitArray.
Emit attributes, inner text, and CDATAbuild-attributes-and-text
const builder = new Builder({ cdata: true })
const xml = builder.buildObject({
root: { $: { id: 'my id' }, _: 'my inner text' },
})
// <root id="my id"><![CDATA[my inner text]]></root>
// namespaces are just attributes:
// { 'foo:Foo': { $: { 'xmlns:foo': 'http://foo.com' } } }The $ and _ keys are the same convention the parser produces, which is what makes round-tripping work. cdata only wraps text that actually needs escaping, so it is not a way to force CDATA everywhere.
Drop the XML declaration and change indentationcontrol-output-format
const builder = new Builder({
headless: true,
rootName: 'doc',
renderOpts: { pretty: true, indent: ' ', newline: '\n' },
xmldec: { version: '1.0', encoding: 'UTF-8', standalone: false },
})
console.log(builder.buildObject({ a: { $: { x: '1' }, _: 'hi' } }))
// <doc>
// <a x="1">hi</a>
// </doc>renderOpts, xmldec, doctype, and headless are handed straight to xmlbuilder, so its documentation is the real reference for them. Set pretty: false for wire payloads, since the indentation whitespace becomes text nodes for whoever parses your output.
Use friendlier keys than $ and _rename-attribute-and-text-keys
const result = await parseStringPromise(xml, {
attrkey: 'attributes',
charkey: 'text',
explicitArray: false,
trim: true,
})
// { root: { attributes: { id: '7' }, item: [...] } }Rename these consistently on both Parser and Builder or a round trip will emit your renamed keys as literal elements. trim: true removes surrounding whitespace from text nodes, which the parser leaves in place by default.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| fast-xml-parser | npm | You are writing new code: faster, bundled TypeScript types, ESM and CJS, entity handling options, and an active release cadence. |
| saxes | npm | Documents are too big to hold in memory and you want an event stream with constant memory, at the cost of writing the tree assembly yourself. |
| xmlbuilder2 | npm | You only need to generate XML, not parse it, and want a modern typed successor to the xmlbuilder that xml2js depends on. |