Skip to content
datahacks.dev
XML

Parsing XML in Java C Sharp and Other Typed Languages

One XML file and one task in eight languages, with each output, the defaults that change your data, and how each treats absent, empty and bad values.

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

XML has been in every language's standard library or a popular package for twenty years, and the APIs look different enough that code in one hardly helps in another. The differences are less in the structure than in the defaults: what the library does with a number, an absent element, a broken boolean, a namespace, an entity. Those defaults decide whether the code you write is correct or only looks correct.

This guide takes one small XML file and one small task, and does it in each language, with the output of each run. The task is the one that real loaders do: read the books, collect the authors, get the price and currency, tell an absent note from an empty one, add up the US dollar prices exactly, read a namespaced variant, handle a broken document, and stream a large file. The spokes in this cluster take each language further.

The file and the task

library.xmlXML
<library updated="2026-10-01">
  <book id="b1" available="true">
    <title>Cache Patterns</title>
    <author>Ana Lee</author><author>Bo Chen</author>
    <price currency="USD">29.50</price>
    <note>Covers R&amp;D</note>
  </book>
  <book id="b2" available="false">
    <title>SQL Tuning, 2nd Edition</title>
    <author>Cy Ortiz</author>
    <price currency="USD">44.00</price>
  </book>
  <book id="b3" available="true">
    <title>Indexes "Explained"</title>
    <author>Di Park</author>
    <price currency="VND">250000</price>
    <note/>
  </book>
</library>

Every language was asked to produce the same three lines and the same total: book id, title, authors, amount and currency, availability, and the note as absent, empty or text, with a USD total of 73.50. The file is built to contain the cases that distinguish libraries: a repeated element (two authors in one book, one in the others), an attribute on the value (currency), a boolean in an attribute, an absent element and an empty element, a comma and quote characters in titles, and an entity.

All eight implementations printed the same content. They differ in the next section.

How they behave on the awkward cases

Absent vs empty noteBad boolean (maybe)MoneyNamespace
Java, JAXBnull vs ""**silently false** unless an event handler is setBigDecimalnamespace on the annotation
C#, XDocumentnull vs "" through (string?)cast throws FormatExceptiondecimal, culture trapXNamespace + "name"
Go, encoding/xmlnil pointer vs pointer to ""strconv.ParseBool errorstring, no decimal in stdlib"ns name" in the tag
Rust, quick-xml + serdeNone vs Some("")invalid type errorstring; add a decimal crateNsReader
PHP, SimpleXMLisset false vs true(bool) cast of "false" is truestring; floats or integer centschildren($uri)
Ruby, Nokogiriat_xpath returns nil vs a nodecompare strings yourselfBigDecimalxpath prefix map
Node, fast-xml-parserundefined vs ""strings (with parseAttributeValue off)number by default: 29.5prefix stays in keys
Browser, DOMParserundefined vs empty nodestringsstringsgetElementsByTagNameNS

Read the table by column. Absent versus empty is preserved by every language, each in its own way: a nullable, a pointer, an option or a query that returns nothing. This is good news; you just have to use it, and the way you use it should be a decision made once (handling optional XML elements and missing values). A bad boolean is where the languages disagree most. Go and Rust refuse it, C# refuses it when you cast, Java's JAXB accepts it and gives false, and PHP turns the string "false" into true. Money is a string in several languages and a proper decimal in a few, and should be an exact type or integer cents everywhere. Namespaces are a separate call or prefix everywhere, with no exception.

Java

Three APIs ship in the JDK (DOM, SAX, StAX), and binding comes from Jakarta XML Binding as a dependency. DOM needed between 256 and 512 MB of heap to parse the 36.6 MB file and took 1.03 seconds with a large heap. StAX counted the same 200,000 orders in 0.32 seconds at 32 MB. StAX plus JAXB bound each order to an object in 1.2 to 1.5 seconds at 16 MB and gave an exact BigDecimal total. The traps: the default DocumentBuilderFactory reads files named by external entities, and JAXB accepts bad data unless an event handler is installed. See DOM versus SAX versus StAX parsing in Java, reading XML in Java with JAXB annotations and streaming large XML files in Java without memory trouble.

C#

XDocument with explicit casts is the reading idiom: (string?)b.Element("note") is null for absent, empty for empty. Bad values throw. The trap is the culture: decimal.Parse("29.50", new CultureInfo("de-DE")) returned 2950, while the invariant culture gave 29.50, and a server with German regional settings reads prices wrong without an error. The XmlSerializer writes xmlns:xsi and xmlns:xsd declarations and a utf-16 declaration when it writes to a StringWriter, both removable. See reading XML in C# with XDocument and LINQ to XML and serializing C# objects to XML.

Go

encoding/xml unmarshals into tagged structs. A *string field distinguishes absent from empty; the standard conversions return errors for bad values (strconv.ParseBool: parsing "maybe": invalid syntax); a root element mismatch is rejected with expected element type <library> but have <other>. It does not resolve external entities (invalid character entity &x;). A Decoder.Token loop counted 200,000 orders in 0.82 seconds. No decimal type: keep money as text. See parsing XML in Go with encoding/xml.

Rust

quick-xml with serde maps attributes with @name renames and element text with $text. Option models optional elements. Errors are typed: invalid type: string "maybe", expected a boolean, and a mismatched tag gives ill-formed document: expected </b>, but </a> was found. The event reader counted 200,000 orders in 0.09 seconds in a release build, the fastest in this comparison by a factor of three or more. See parsing XML in Rust with quick-xml.

PHP

SimpleXML is the shortest way to read a small file, and the object model sets a trap: (bool)$x->book[1]['available'] is true for a book whose available is the text false, because the attribute object is truthy. Compare strings. Namespaces need children($uri). Errors need libxml_use_internal_errors(true), or they print warnings. LIBXML_NOENT turns external entities on. XMLReader counted 200,000 orders in 0.44 seconds, and PHP's own memory counter reported 2 MB because libxml allocates outside it. See reading XML in PHP with SimpleXML and DOMDocument.

Ruby

Nokogiri's default parse is lenient: a mismatched tag was repaired into a well-formed document and logged one error, and strict makes it raise with the position. noent makes external entities read files. BigDecimal keeps the money exact. Reader counted 200,000 orders in 1.38 seconds. See reading XML in Ruby with Nokogiri.

JavaScript, in the browser and in Node

In the browser, DOMParser never throws; a broken document comes back with a parsererror element, which I found as a child of the root in Chromium, in the XHTML namespace. In Node, fast-xml-parser defaults drop attributes, turn 29.50 into the number 29.5 and 007 into 7, and make a single author a string and two an array unless isArray says otherwise. With explicit options the same library returned exact strings. Parsing the 36.6 MB file into one object took 4.4 seconds and 695 MB. See parsing XML in JavaScript with DOMParser and parsing XML in Node with fast-xml-parser.

Streaming speed side by side

The same count of 200,000 order elements in the 36.6 MB file:

Language and APITime
Rust, quick-xml events, release build0.09 s
Java StAX (includes JVM start-up)0.32 s
PHP XMLReader0.44 s
Go Decoder.Token0.82 s
Ruby Nokogiri::XML::Reader1.38 s
Java StAX with JAXB, typed objects1.2 to 1.5 s
Node fast-xml-parser, whole tree4.4 s (695 MB)

Three notes on reading the table. The times are one run each on one laptop, with different amounts of start-up included, so differences under a factor of two are not meaningful. All of them are fast enough that parsing is rarely the bottleneck in a loader; the database insert and the validation usually are. And the memory column matters more than the time column: the whole-tree entries (DOM, SimpleXML, fast-xml-parser) scale with the file, and the streaming entries do not.

Entities: the one default that matters

On the three-line external entity file, Java's default parser read the host's name file; Go, Rust, Node and the browser refused or ignored the entity, PHP and Ruby returned an empty string by default, and lxml refused. Each of PHP, Ruby and lxml could be flipped to read the file with one option whose name does not announce the danger (LIBXML_NOENT, noent, resolve_entities=True). The language does not make the code safe; the options do. Put the safe configuration in one function per service and test it with the attack file (XML security problems to close before production).

Namespaces in every language

Every library returned zero for the namespaced file when asked for the plain name: XDocument.Elements("book"), SimpleXML property access, Nokogiri //book, getElementsByTagName("book"). Each has its own way to say the namespace: XNamespace + "book", children($uri), an XPath prefix map, getElementsByTagNameNS. Declare the namespace as a constant and use it everywhere. A zero where you expected data is a namespace until proven otherwise (how XML namespaces work).

Type mapping, language by language

The same four XML values need a decision in every language, and the decisions are where bugs are born. Here is how I map them, and what each library gives by default.

Text identifiers (b1, 00123) stay strings in all of them. The libraries that convert numbers by default (fast-xml-parser) lose the leading zero; the ones that do not (every other) keep it. Map by field name, not by looking at the value.

Amounts (29.50) need an exact type. Java has BigDecimal and JAXB maps to it directly. C# has decimal, with the culture rule. Ruby has BigDecimal. Go, Rust, PHP and JavaScript have none in the standard library: keep the text and convert to integer cents at the boundary, or add a decimal package (shopspring/decimal in Go and rust_decimal in Rust are the usual ones; I did not test either). In every case, keep the currency beside the amount: the sample file has 250000 in dong next to 29.50 in dollars.

Booleans (true, false, and in some formats 1, 0, Y, N) need a mapping function that rejects unknown spellings. The languages that parse booleans for you accept different sets: Go's ParseBool takes 1, t, T, TRUE, true, True and their opposites, XML Schema allows true, false, 1, 0. If the contract says true and false, write that, and fail on anything else.

Dates (2026-10-01) become date types with a parser per language, and the time zone is the trap: a date with no zone is a local date, and converting it to a timestamp invents a zone. Keep dates as dates.

Repeated elements become lists in the typed languages and need a flag in the untyped ones (isArray, force_list), for the reason every article in this cluster repeats: a list of one looks like a single value.

Absent versus empty have a native representation almost everywhere and, in the end, a policy question: is the empty note the same as no note? In most feeds it is, and one function should collapse them. Decide once.

One error policy for all of them

Services in several languages should fail the same way on the same input, otherwise the Java importer rejects a file that the Python worker accepts. I write the policy as a short list and keep it in the repository next to the shared fixture files.

  1. Not well-formed: reject the whole document with the parser's message and position. Never recover.

  2. Not valid against the schema: reject, with the first few errors (see validating XML against an XSD schema).

  3. A required field is missing or empty: reject the record, and decide whether the document survives.

  4. A value does not parse: reject the record with the field name and the value.

  5. Unknown elements and attributes: ignore, and count them. A new element in a feed is information.

  6. Counts do not match: if the document states a count, or the sender gave one, compare after reading and fail on a mismatch.

Every language in the comparison can implement these. Go and Rust implement 4 by default. Java needs the event handler. PHP needs the explicit string comparison. The point of the list is that the behavior is written down and tested, not inherited from a library default that differs between stacks.

A shared fixture suite

The cheapest way to keep several implementations aligned is a folder of small XML files and a table of expected results, run by a test in each language. The files in this guide are a start: the library file, the namespaced file, the broken tag, the external entity, the bad boolean. For each file, the table says what a reader must return or which error it must raise. A new language, or a new version of a library, is then checked in minutes, and a behavior change shows up as a failing case with a name.

Include the awkward ones deliberately. A book with one author and a book with none. An empty note and an absent note. An ampersand and a quote in a title. A price with a currency attribute and one without. A file in a different encoding with a declaration, one with a byte order mark and one with a lie in the declaration. A namespaced file with two prefixes for the same URI. An entity declaration. A file ten times deeper than you allow. These are the cases that, in my experience of the runs in this cluster, separate the libraries; every one produced a different answer somewhere.

Reusing parsers and threads

Parsers are objects with configuration, and their cost and thread safety differ. In Java, DocumentBuilderFactory and XMLInputFactory are expensive to create and are not guaranteed to be thread-safe, so create one configured factory and a fresh builder or reader per use, or keep one per thread. In .NET, XmlSerializer construction is slow for non-trivial types and instances are documented as thread-safe for serialization and deserialization, so create it once and share it. In Go, an xml.Decoder is for one goroutine; xml.Unmarshal on separate inputs is safe to call concurrently. In PHP and Ruby the question is mostly about processes, and the point is to configure the parser per request, not once globally, because options such as libxml_use_internal_errors are process-wide state in PHP. In every language, the configured factory belongs in one place: the thing to avoid is a hundred new calls, each with its own defaults, in a hundred files.

Choosing a language for XML work

If you have a free choice, a few observations from these runs.

For speed on large files, Rust's event reader was in a class of its own, and Java StAX, PHP's XMLReader and Go's Decoder were all fast enough that the rest of the pipeline dominates. For correctness by default, Go and Rust, which refuse bad values and do not resolve entities, need the least configuration. For small files and glue, Python and Ruby make short scripts, and the browser's DOMParser is free in a page. For document-style XML with schemas and XSLT, the Java and .NET stacks have the deepest tooling. For binding to big existing schemas, generated classes (xjc, xsd.exe) beat hand-written ones in the languages that have a generator.

None of this should override the language your team already runs. The defaults are fixable in every one of them; the cost is in writing the list above, and in keeping the tests.

Writing XML back out

Reading is half the job, and the writing side has its own defaults. Three of the languages in this comparison produced XML from objects, and each added or chose something I had not asked for.

Java's JAXB Marshaller wrote <?xml version="1.0" encoding="UTF-8" standalone="yes"?>, indented with four spaces when JAXB_FORMATTED_OUTPUT was set, wrote the book's id and available attributes in the class order and escaped the ampersand in the note as Covers R&amp;D. The standalone="yes" is harmless for most consumers and surprising to some validators.

C#'s XmlSerializer wrote a root with xmlns:xsi and xmlns:xsd declarations that nothing used, and an encoding="utf-16" declaration because it wrote into a StringWriter. An XmlSerializerNamespaces with an empty entry removed the declarations, and writing through an XmlWriter with a UTF-8 setting fixed the encoding. A null property was left out entirely and a boolean was written as true.

Go's xml.MarshalIndent wrote the Book struct with a two-space indent, used the struct's tags for names and attributes (<Book id="b1" available="true">) and the field name for the root, since the type name Book is the default element name when there is no XMLName. The declaration is not automatic: xml.Header has to be prepended.

The lessons repeat from the sanitizing article. Use the library's writer and never string formatting, because all three escaped the ampersand without being asked. Check the encoding of the declaration against the bytes you write. And keep a golden file of the output in a test, since a renamed field or a changed default changes what a partner receives (sanitizing data you put into an XML document, serializing C# objects to XML).

From records to a database

Most loaders end in a database, and the language choice matters less there than the batching. In each language the pattern is the same: read a record, convert it, add it to a batch of a few thousand, write the batch with the database's bulk path, and commit. The streaming readers above produce records one at a time, so the batch is the only place memory grows. What differs is the bulk path: JDBC batch statements in Java, SqlBulkCopy or provider batching in .NET, COPY through a driver in Go, Rust and Python for PostgreSQL, COPY or multi-row inserts in PHP and Ruby. The thing to measure in your own stack is the cost of row-by-row inserts against bulk loading: in the DuckDB test elsewhere in this series, inserting 49,819 rows one statement at a time took 24.6 seconds against 0.07 seconds when the same rows went in as a DataFrame, a 350-fold difference that had nothing to do with XML (analysis on XML data when nobody has converted it yet).

Two checks belong in every language's loader. Count the records read and the rows written, and fail if they differ without an explained reject count. And compute one aggregate in the loader (a sum of amounts per currency) and compare it with the same aggregate in the database after the load. The loader pattern is described in detail for Python and PostgreSQL in loading large XML files into a database in chunks; the structure carries over to the other languages unchanged.

Handling what the format does not say

A last class of problem has no language-specific fix, because it is information the XML does not carry. The sample file cannot tell you whether 250000 is a price in dong or a typo; whether the books' available flag means in stock or in print; whether the empty note means there was none or the author cleared it. No library resolves these. They go in a data dictionary beside the schema, they get a named owner, and the loader enforces what it can (a currency from a known list, a range for the amount). When a sender changes the meaning of a field without changing its name, the parse will succeed in every language and the data will be wrong in all of them, and the only defense is a check against something outside the file: a count, a total, last month's distribution. That is why the reconciliation steps in this series appear in every cluster. The parser's job ends at "this is what the file says".

What to carry between languages

If you work in several languages, these habits transfer:

  • Read bytes, let the parser decode. Never decode first.

  • Parse numbers yourself, explicitly, with an exact type, with the invariant format.

  • Compare booleans as strings unless the library validates against a schema.

  • Say what absent and empty mean in one place.

  • Turn the dangerous options off explicitly, and test.

  • Stream anything that can grow, and bind one record at a time if you want types.

  • Test with the awkward file: repeated element, one-item list, absent, empty, ampersand, namespace, broken tag.

The library file in this guide is that awkward file. Keep it, or one like it, in your test suite and run each reader against it. A reader that prints the same three lines and the same 73.50 as the others has passed the first test.

The spokes in this cluster: DOM versus SAX versus StAX parsing in Java, reading XML in Java with JAXB annotations, reading XML in C# with XDocument, serializing C# objects to XML, parsing XML in Go with encoding/xml, parsing XML in Rust with quick-xml, reading XML in PHP with SimpleXML, reading XML in Ruby with Nokogiri, parsing XML in JavaScript with DOMParser, parsing XML in Node with fast-xml-parser and streaming large XML files in Java. For Python, see parsing and writing XML in Python.

The references are the language documentation linked in each article, plus the XML 1.0 specification and the Namespaces in XML recommendation for the rules every library implements. Test an XPath in the XPath tester.

When none of these libraries is the right layer

The data is in a database. Use the database's XML functions or load the records into tables, and read tables from your language (turning XML feeds into relational tables).

The format has a code generator. Standards such as invoices and healthcare messages come with schemas and generated classes. Use those, and keep the parser settings above underneath.

The work is a one-off. A shell tool (handy command-line tools for XML) or a Python script is faster than a typed implementation in your production language.

The XML is a document. Mixed content and inline markup are poorly served by object binding. Keep the tree and use XPath or XSLT.

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.