Skip to content
datahacks.dev
XML

DOM Versus SAX Versus StAX Parsing in Java Compared

The three Java XML APIs on one 36.6 MB file: DOM ran out of memory at a 256 MB heap and took 1.03 s with 1 GB, while SAX and StAX ran in 32 MB in about 0.3 s.

7 min read
Summarize in AI
Flat illustration of two options weighed side by side, with the XML logo.

Java ships three ways to read XML in the standard library, and the choice among them decides how much memory the program needs, how much code you write and what you can do with the result. Each is correct for some job. I ran all three on the same file and the same task so the differences are numbers, not opinions.

The task and the three answers

All three programs print the same line: 200,000 orders, a total of 99581500.0. They differ in how they got there.

DOM (javax.xml.parsers.DocumentBuilder) reads the whole file into a tree of Node objects. You then navigate it with getElementsByTagName and friends. Random access is easy and the tree is in memory.

Dom.javaJava
var f = DocumentBuilderFactory.newInstance();
f.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
Document d = f.newDocumentBuilder().parse(new File(a[0]));
NodeList orders = d.getElementsByTagName("order");
BigDecimal sum = BigDecimal.ZERO;
for (int i = 0; i < orders.getLength(); i++) {
    Element o = (Element) orders.item(i);
    sum = sum.add(new BigDecimal(o.getElementsByTagName("total").item(0).getTextContent()));
}

SAX (SAXParserFactory) reads the file once and calls your handler methods as it meets start tags, text and end tags. Nothing is kept unless you keep it.

Sax.javaJava
var h = new DefaultHandler() {
    int orders; BigDecimal sum = BigDecimal.ZERO; boolean inTotal; StringBuilder buf = new StringBuilder();
    public void startElement(String u, String l, String q, Attributes at) {
        if (q.equals("order")) orders++;
        if (q.equals("total")) { inTotal = true; buf.setLength(0); }
    }
    public void characters(char[] c, int s, int n) { if (inTotal) buf.append(c, s, n); }
    public void endElement(String u, String l, String q) {
        if (q.equals("total")) { sum = sum.add(new BigDecimal(buf.toString())); inTotal = false; }
    }
};
f.newSAXParser().parse(new File(a[0]), h);

StAX (XMLInputFactory and XMLStreamReader) is a pull parser: your loop asks for the next event and decides what to do.

Stax.javaJava
var f = XMLInputFactory.newInstance();
f.setProperty(XMLInputFactory.SUPPORT_DTD, false);
f.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
XMLStreamReader r = f.createXMLStreamReader(in);
while (r.hasNext()) {
    if (r.next() == XMLStreamConstants.START_ELEMENT) {
        if (r.getLocalName().equals("order")) orders++;
        else if (r.getLocalName().equals("total")) sum = sum.add(new BigDecimal(r.getElementText()));
    }
}

The numbers

I ran each with three heap sizes:

Text
### -Xmx1g
DOM   orders=200000 total=99581500.0 time=1.03s
SAX   orders=200000 total=99581500.0 time=0.29s
StAX  orders=200000 total=99581500.0 time=0.32s
### -Xmx128m
SAX   ...  time=0.29s
StAX  ...  time=0.31s          (DOM produced no result line)
### -Xmx32m
SAX   ...  time=0.30s
StAX  ...  time=0.32s

DOM at 256 MB died with java.lang.OutOfMemoryError: Java heap space (128 MB failed as well); at 512 MB it ran in 0.82 seconds, and at 768 MB in 0.87. The file is 36.6 MB, so DOM needed between 256 and 512 MB, a ratio of about 7 to 14 times. SAX and StAX did not notice the heap shrinking from 1 GB to 32 MB. That is the headline: streaming parsers use memory proportional to the depth of the document and the size of one record, not the size of the file. DOM is also slower, 1.03 seconds against about 0.3, since it allocates millions of objects (the garbage collector does the extra work).

Each time is one run and includes JVM start-up and compilation of the source file, so read the numbers as ratios and not as benchmarks.

What each costs you in code

The SAX handler is the clumsiest of the three, and the reason is worth understanding. The parser calls characters with pieces of text, and a single text node can arrive in several calls (at buffer boundaries and around entity references). So you cannot read the value in the callback; you accumulate it in a buffer and use it in endElement. You also track where you are by hand with the inTotal flag, and for nested structures that becomes a stack. A handler that extracts three fields from different depths is thirty lines of state.

StAX does the same job with a loop that reads like the problem. getElementText() collects the text of a simple element and returns it in one call, so the "state" is the position in your own loop. When the structure is nested, you write nested loops or helper methods that consume events until the matching end tag. For most programs I would write today, StAX is the streaming API to use. SAX still has a place when you need to feed events into something that expects a ContentHandler (an XSLT transformer, a validator), and when you want push-style filters chained together.

DOM is the simplest to write and the most flexible: you can walk back and forth, query with XPath (XPathFactory), edit the tree and serialize it. It is the right tool for small documents that you modify or query in several ways.

Choosing

IfUseBecause
The document is under tens of MB and you need random access or editsDOMSimplest code; memory is acceptable
You read each record once, the file may be largeStAXStreaming with readable code
You feed events to another component, or you maintain a SAX pipelineSAXPush model, composable filters
You map to objects and the file is smallJAXB or another binderLess code than any of these
You map to objects and the file is largeStAX plus JAXB per recordStream records, bind one at a time

The last row is the useful combination for big files: use a XMLStreamReader to walk to each order element, then hand the reader to JAXB's unmarshal(reader, Order.class), which binds one record and leaves the reader positioned after it. Memory stays flat and the code is typed (reading XML in Java with JAXB annotations, streaming large XML files in Java without memory trouble).

What the memory number means in practice

The ratio of 7 to 14 times the file size is why DOM is a decision and not a default. A service that parses uploads of up to 10 MB with DOM needs on the order of 140 MB of heap per concurrent request, and ten concurrent requests need a gigabyte and a half. The same service with StAX needs a few megabytes per request whatever the upload size. The JVM also keeps the tree alive until it is unreachable, and the garbage collector then has to traverse millions of small objects, which is where much of the 1.03 seconds went compared with 0.3.

If you must have a tree and the file is large, there are middle paths. Stream with StAX and build a small DOM for each record, which is memory-bounded and gives you XPath on the record. Or read each record into a DocumentFragment or a bound object, process it and drop it. The pattern is the same as iterparse in Python: the unit of memory is the record, not the file.

Security settings, again

All three programs disable what the defaults leave on. In the earlier test, a default DocumentBuilderFactory read /etc/hostname through an external entity. For DOM and SAX the strict switch is disallow-doctype-decl; for StAX it is SUPPORT_DTD set to false together with IS_SUPPORTING_EXTERNAL_ENTITIES set to false. Put the configuration in one factory method and use it everywhere (disabling DTDs and external entities in Java and Python).

For the broader language comparison see parsing XML in Java, C# and other typed languages. The same streaming idea in Python is in parsing large XML files with iterparse. The APIs are documented in the JAXP guide and the StAX package documentation. Try the XPath you would use with DOM in the XPath tester.

When none of the three is the right choice

The data is already in a database. Query it there rather than shipping the XML into the JVM.

You need a typed model of a big file. Stream with StAX and bind records with JAXB, or use a library that does both.

The input is hostile and large. A streaming parser limits memory but not time or depth. Add a size cap and a time limit (limiting XML size and depth for uploads).

You want XPath over a large file. DOM supports XPath but needs the tree. A streaming approach with a small matcher, or a database with XML support, scales better.

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.