Runtime
Bundler
Package Manager
Test Runner
Guides
Reference
Blog
Install Bun
Runtime Utilities

XML

Use Bun's built-in support for XML through both runtime APIs and bundler integration

In Bun, XML is a first-class citizen alongside JSON, TOML, YAML, and JSON5. You can:

  • Parse and stringify XML with Bun.XML.parse and Bun.XML.stringify
  • import & require XML files as modules at runtime (including hot reloading & watch mode support)
  • import & require XML files in frontend apps with Bun's bundler

Conformance

Bun's XML parser is written in Rust and implements XML 1.0 (Fifth Edition) as a non-validating processor that does not read external entities:

  • The whole document, including the internal DTD subset, must be well-formed — anything else throws a SyntaxError.
  • Internal entities declared in the document are expanded (with expansion limits, so "billion laughs" payloads fail instead of exhausting memory), attribute values are normalized, and attribute defaults declared in the internal subset are applied.
  • External DTDs and external entities are never fetched or read, so there is no XXE surface. In a document with no DTD, a reference to an undeclared entity is an error; when the DOCTYPE points at an external subset (or uses parameter entities) that could have declared it, the reference is kept as written (  stays  ), unless the document says standalone="yes".
  • Nothing is validated against the DTD, namespaces are not resolved (prefixed names are kept verbatim), and comments and processing instructions are skipped.

It is run against the W3C XML Conformance Test Suite: all 1,679 cases that have a required outcome for this class of processor pass — not-well-formed documents are rejected, well-formed ones are accepted and, where the suite gives one, their element tree matches its canonical output byte for byte. The translated test suite lists every case, including the ones whose outcome legitimately depends on not reading external entities.


Performance

The parser works in two stages, like Bun's JSON parser: a SIMD pass (runtime-dispatched AVX2/AVX-512/NEON/SVE kernels) records the position of every byte that can change the parse — <, >, &, line ends, quotes and = inside tags — and the parser then hops from one of those positions to the next, so character data, attribute values, comments and CDATA sections are never scanned a byte at a time. The result is written as flat rows and turned into JavaScript objects in a single pass that reuses JavaScriptCore's atom-string cache for element and attribute names, the same way JSON.parse does. A JS string is parsed in place in whatever representation the engine holds it in (Latin-1 or UTF-16) and the strings in the result share that representation, so nothing is transcoded on the way in or out; Buffer and Blob input is parsed as UTF-8.

bench/xml/xml.mjs in the Bun repository compares Bun.XML.parse with popular npm parsers on the same documents (lower is better; Linux x64, one core):

DocumentBun.XML.parsetxmlfast-xml-parser@xmldom/xmldomxml2js
S3 ListObjectsV2 response, 231 KB1.1 ms4.0 ms23 ms31 ms19 ms
Atom feed, 193 KB1.1 ms3.7 ms19 ms23 ms16 ms
libphonenumber metadata, 960 KB5.3 ms9.6 ms56 ms53 ms
Chromium enums.xml, 1.4 MB16 ms41 ms150 ms103 ms
freedesktop MIME database, 2.2 MB27 ms56 ms299 ms280 ms

Roughly half of Bun.XML.parse's time on these documents is creating the JavaScript objects rather than parsing. Measured at the native level (scripts/bench-json-rust.sh --xml, parse to the in-memory tree, MiB/s, higher is better), against widely used C, C++ and Rust parsers:

DocumentBunpugixmlquick-xmlexpatroxmltreelibxml2
SVG drawing (path data), 1.2 MB3,6002,200960170290630
Vulkan vk.xml, 3.2 MB34063024013011041
Chromium enums.xml, 1.4 MB32070027511410635
libphonenumber metadata, 960 KB44084058017017588
freedesktop MIME database, 2.4 MB2105852401209130

Unlike the fastest C++ parsers, Bun's parser checks everything XML requires of a well-formed document (valid UTF-8, legal characters, unique attributes, entity expansion limits) and expands entities declared in the DTD; attribute- and text-heavy documents are where the SIMD stage pays off most.


Runtime API

Bun.XML.parse()

Parse an XML document into a plain JavaScript object.

import { XML } from "bun";

const data = XML.parse(`
  <order id="A1" currency="USD">
    <customer>Ada</customer>
    <item sku="tea" qty="2">Green tea</item>
    <item sku="mug" qty="1">Mug</item>
    <paid/>
  </order>
`);

console.log(data);
// {
//   order: {
//     "@id": "A1",
//     "@currency": "USD",
//     customer: "Ada",
//     item: [
//       { "@sku": "tea", "@qty": "2", "#text": "Green tea" },
//       { "@sku": "mug", "@qty": "1", "#text": "Mug" },
//     ],
//     paid: "",
//   },
// }

By default the result is a compact object keyed by element name — the shape most XML-to-object libraries use:

  • The result has one key, the root element's name.
  • An element with no attributes and no child elements becomes its text content, trimmed of surrounding whitespace ("" when empty).
  • Any other element becomes an object with a "@name" key per attribute, one key per distinct child element name — an array when that name repeats, in document order — and "#text" for its trimmed character data, if any.
  • CDATA sections and entity references are already expanded into the text. Comments and processing instructions are dropped.
  • All values are strings. Nothing is coerced to numbers, booleans, or null.

The compact shape does not keep the relative order of differently named siblings or of text between child elements. When that matters — documents rather than data — pass { compact: false } to get the root element as a node tree that keeps everything in document order:

const p = XML.parse(`<p class="lead">Hello <b>world</b>!</p>`, { compact: false });

console.log(p);
// {
//   name: "p",
//   attributes: { class: "lead" },
//   children: [
//     "Hello ",
//     { name: "b", attributes: {}, children: ["world"] },
//     "!",
//   ],
// }

Every element is { name, attributes, children }; children holds child elements and strings, and text is passed through exactly (including whitespace-only runs between elements).

Input types and encodings

XML.parse accepts a string, or bytes as a Buffer, TypedArray, ArrayBuffer, or Blob.

A string is already-decoded text, so its encoding declaration is checked for syntax but otherwise ignored. Bytes are decoded per the XML rules: a byte-order mark or the encoding in <?xml version="1.0" encoding="..."?> selects UTF-8 (the default), UTF-16 (either byte order), or ISO-8859-1. Other encodings throw.

XML.parse(await Bun.file("feed.xml").bytes());

Error handling

Bun.XML.parse() throws a SyntaxError when the document is not well-formed:

try {
  XML.parse("<a><b></a>");
} catch (error) {
  console.error(error.message); // "XML Parse error: Expected closing tag </b> but found </a>"
}

Bun.XML.stringify()

Serialize either shape back to XML. The output has no XML declaration and is always well-formed: &, <, > (and, in attributes, quotes, tabs and newlines) are escaped, and element or attribute names that are not XML names throw.

import { XML } from "bun";

XML.stringify({
  order: {
    "@id": "A1",
    customer: "Ada",
    item: [{ "@sku": "tea", "#text": "Green tea" }, { "@sku": "mug" }],
    paid: null,
  },
});
// '<order id="A1"><customer>Ada</customer><item sku="tea">Green tea</item><item sku="mug"/><paid/></order>'

XML.stringify({
  name: "p",
  attributes: { class: "lead" },
  children: ["Hello ", { name: "b", children: ["world"] }, "!"],
});
// '<p class="lead">Hello <b>world</b>!</p>'

A value with a string name and a children or attributes property is written as a node; anything else is a compact object and must have exactly one key naming the root element. Strings, numbers, booleans, bigints and Dates (as ISO strings) become text, null becomes an empty element, and undefined, functions and symbols are skipped like JSON.stringify skips them (unlike JSON.stringify, a bigint is written as its decimal digits rather than rejected).

Pretty printing

Pass a space argument (a number of spaces or an indent string, as with JSON.stringify) to indent element-only content. Elements that contain text are written inline so character data is unchanged:

console.log(XML.stringify(data, null, 2));
// <order id="A1" currency="USD">
//   <customer>Ada</customer>
//   <item sku="tea" qty="2">Green tea</item>
//   <item sku="mug" qty="1">Mug</item>
//   <paid/>
// </order>

XML.parse(XML.stringify(value)) gives back value for anything XML.parse produced, in either shape.


Module Import

ES Modules

You can import XML files directly. Files are decoded like bytes passed to XML.parse (UTF-8, UTF-16, or ISO-8859-1 per the byte-order mark or declaration), and the module's value is the compact object described above:

config.xml
<?xml version="1.0" encoding="UTF-8"?>
<config env="production">
  <database host="localhost" port="5432" name="myapp"/>
  <feature name="auth"/>
  <feature name="rateLimit"/>
</config>

Default Import

app.ts
import doc from "./config.xml";

console.log(doc.config["@env"]); // "production"
console.log(doc.config.database["@host"]); // "localhost"
console.log(doc.config.feature.map(f => f["@name"])); // ["auth", "rateLimit"]

Named Import

The root element is also available as a named import:

app.ts
import { config } from "./config.xml";

console.log(config.database["@port"]); // "5432"

CommonJS

app.ts
const { config } = require("./config.xml");
console.log(config.database["@name"]); // "myapp"

Import Attributes

Use with { type: "xml" } to parse a file with another extension as XML:

import feed from "./export.rss" with { type: "xml" };

Hot Reloading with XML

When you run your application with bun --hot, Bun reloads XML files when they change:

server.ts
import { config } from "./config.xml";

Bun.serve({
  port: 3000,
  fetch(req) {
    return new Response(`Running in ${config["@env"]} against ${config.database["@host"]}`);
  },
});
terminal
$ bun --hot server.ts

Bundler Integration

When you bundle with Bun, imported XML files are parsed at build time and inlined as JavaScript objects:

terminal
$ bun build app.ts --outdir=dist

Parsing at build time means:

  • Zero runtime XML parsing overhead in production
  • Smaller bundle sizes
  • Tree shaking of unused properties

Dynamic Imports

XML files can be dynamically imported:

const { default: doc } = await import("./config.xml");