Reading and Writing XML
XML is useful when a protocol or document format needs named elements, attributes, and text in a defined order. This page shows how to parse an XML document, build and serialize one, and navigate its nodes. It also explains what happens to mixed content and entity references, and how to set limits when XML comes from an untrusted source.
What Is an XML Document?
An XML document has one root element.
Elements can contain text and other elements, while attributes attach named text values to an element.
A document can also have an XML declaration, a DOCTYPE declaration, comments, or processing instructions around its
root.
Inside an element, the order matters: in <gruppe>Solo: <instrument>fløjte</instrument> og obo</gruppe>, the words
before and after instrument are separate pieces of text.
That makes XML suitable for both structured records and documents with mixed prose and markup.
Erbsland Core represents an XML document with XmlDocument and its
ordered XmlNode objects.
This is a document tree rather than a collection of typed scalar values: numbers, dates, and booleans in XML are text
until your application interprets them.
The document interface uses String for XML text, and the serializer returns a
String.
When an application works with bytes from a file or network connection, it must decode them into a String before
calling the XML parser and encode the resulting String for its destination.
The implementation retains qualified names such as musik:orkester and namespace declarations such as xmlns:musik
as written.
It does not resolve prefixes to namespace URIs or validate namespace bindings.
If your protocol relies on namespaces, check the relevant names and declarations in the parsed document according to
that protocol.
The parser preserves XML and DOCTYPE declarations but does not use them to decode bytes or validate a DTD or schema.
Parsing XML Text
Pass one complete document to XmlDocument::fromString.
It returns an optional document, which is convenient when invalid input is an expected validation result.
Use fromStringOrThrow when you need a diagnostic;
malformed or unsupported input raises ParseError.
Both forms require exactly one root element and reject malformed nesting, duplicate attributes, and non-whitespace text
outside the root.
This example receives an orchestra document, reads a group attribute, then demonstrates the throwing form on mismatched end tags.
data/DataFormats/XmlExamples.cpp
void parseXmlDocument() {
// Accept a complete document and read a value from its first child element.
const auto source = "<orkester><gruppe navn=\"strygere\"><instrument>violin</instrument></gruppe></orkester>"_el;
if (const auto document = XmlDocument::fromString(source)) {
el::io::printLine("Root: "_el, document->root()->name());
el::io::printLine(
"Group: "_el, document->root()->children().get(el::ItemIndex{0U})->getAttribute("navn"_el).value());
}
// Use the throwing form when the caller needs a parse diagnostic.
try {
(void)XmlDocument::fromStringOrThrow("<orkester><gruppe></orkester>"_el);
} catch (const el::err::ParseError &) {
el::io::printLine("Invalid XML document"_el);
}
}
Output
Root: orkester
Group: strygere
Invalid XML document
Building a Document
Start with an empty XmlDocument and call addRoot once.
On an element, addElement appends a child and returns it, so you
can build the next level immediately.
Its optional text argument creates a text child when nonempty.
Use setAttribute for an attribute: calling it again with the
same name replaces the value in its existing position.
Attributes are text, even if your application uses them for quantities or identifiers.
The orchestra below groups instruments under the root.
The child order is the order in which each addElement() call appears.
data/DataFormats/XmlExamples.cpp
void buildXmlDocument() {
// Append groups and instruments in the order they should appear in XML.
auto document = XmlDocument{};
auto root = document.addRoot("orkester"_el);
root->setAttribute("sted"_el, "Aarhus"_el);
auto group = root->addElement("gruppe"_el);
group->setAttribute("navn"_el, "strygere"_el);
group->addElement("instrument"_el, "violin"_el);
group->addElement("instrument"_el, "cello"_el);
el::io::printLine("Groups: "_el, root->children().count().toSizeT());
el::io::printLine("Instruments: "_el, group->children().count().toSizeT());
}
Output
Groups: 1
Instruments: 2
When you need XML markup beyond plain elements and attributes, create a node with the appropriate XmlNode factory
and place it with XmlDocument::add or
XmlNode::add.
A declaration must be the first document node, and a DOCTYPE belongs before the root.
createDeclaration() and createDocType() take the complete markup text, including delimiters.
Namespace declarations are ordinary attributes in this tree, so the example sets xmlns:musik on the root.
data/DataFormats/XmlExamples.cpp
void buildXmlMarkup() {
// Place document-level markup before the root, then append mixed nodes to it.
auto document = XmlDocument{};
document.add(XmlNode::createDeclaration("<?xml version=\"1.0\"?>"_el));
document.add(XmlNode::createDocType("<!DOCTYPE musik:orkester>"_el));
auto root = document.addRoot("musik:orkester"_el);
root->setAttribute("xmlns:musik"_el, "urn:musik"_el);
root->add(XmlNode::createComment(" første sats "_el));
root->add(XmlNode::createProcessingInstruction("spil"_el, " langsomt"_el));
root->add(XmlNode::createCData("violin & bratsch"_el));
const auto xml = document.toString();
el::io::printLine("DOCTYPE written: "_el, xml.contains("<!DOCTYPE musik:orkester>"_el));
el::io::printLine("CDATA written: "_el, xml.contains("<![CDATA[violin & bratsch]]>"_el));
el::io::printLine("Parsed root: "_el, XmlDocument::fromStringOrThrow(xml).root()->name());
}
Output
DOCTYPE written: true
CDATA written: true
Parsed root: musik:orkester
Serializing XML Text
XmlDocument::toString writes the complete document as a
String.
Ordinary text and attributes are escaped as needed, so an ampersand in a value becomes & on output.
An element without children is written with />.
The writer preserves the order of top-level nodes, child nodes, and attributes.
It does not add indentation or change the content merely to make the output look pretty: whitespace in mixed content can
be meaningful.
The example builds a document whose text contains &, writes it, and reads it back.
The parsed text is the original value, even though the serialized spelling is different.
data/DataFormats/XmlExamples.cpp
void serializeXmlDocument() {
// The writer escapes ordinary text, and the parser restores its value.
auto document = XmlDocument{};
auto root = document.addRoot("instrument"_el, "violin & bratsch"_el);
root->setAttribute("gruppe"_el, "strygere"_el);
const auto xml = document.toString();
el::io::printLine("XML: "_el, xml);
el::io::printLine("Text: "_el, XmlDocument::fromStringOrThrow(xml).root()->textContentOrThrow());
}
Output
XML: <instrument gruppe="strygere">violin & bratsch</instrument>
Text: violin & bratsch
The writer requires one root element.
It also checks the placement of declarations and DOCTYPE nodes, XML names, characters, comments, CDATA, and processing
instructions; invalid constructed content raises ParameterFailure.
A parsed document can be written again, but this is a structural round trip rather than a promise of byte-for-byte
output.
For example, the writer uses double quotes for attributes, escapes text in its own way, and writes empty elements with
/>.
XmlFormatOptions is currently an empty settings type, so there
are no serialization switches to configure.
Understanding the Node Types
XmlNodeType tells you what each node represents.
The table shows what is retained from parsed XML and how to create the corresponding node yourself.
Node type |
Content and access |
Construction |
|---|---|---|
|
Qualified |
|
|
Character data in |
|
|
CDATA content in |
|
|
Comment content in |
|
|
Target in |
|
|
Complete XML declaration markup in |
|
|
Complete DOCTYPE markup in |
|
|
Reference name in |
|
The XmlAttribute objects in attributes() also retain their order
and qualified names.
A parsed attribute’s value() can retain reference spellings from the source; call getAttribute() on its element
when you want decoded text.
A value set with setAttribute() is plain text and will be escaped by the writer.
References and Preserved Markup
XML has five predefined named references, such as & and <, and numeric character references such as
A.
Core can decode those when you ask for an attribute or text content.
Other named references remain distinct EntityReference nodes in element content.
The parser preserves a DOCTYPE declaration, including its internal subset, but does not expand DTD entities or fetch
external resources.
As a result, a document can parse successfully while textContent() returns no value for a subtree with an unresolved
reference.
Do not assume that the declaration of an entity in the DOCTYPE makes its value available to the DOM.
The example keeps declaration, DOCTYPE, CDATA, comment, and entity reference nodes in order.
It can read the CDATA alone, while the group’s attribute and complete text are unavailable because &leder; is
unresolved.
data/DataFormats/XmlExamples.cpp
void inspectXmlMarkup() {
// The DTD entity is retained as a reference, including inside the attribute.
const auto source =
"<?xml version=\"1.0\"?><!DOCTYPE orkester [<!ENTITY leder \"Maja\">]>"
"<orkester><gruppe leder=\"&leder;\"><![CDATA[fløjte & obo]]><!-- solo -->&leder;</gruppe></orkester>"_el;
const auto document = XmlDocument::fromStringOrThrow(source);
const auto group = document.root()->children().get(el::ItemIndex{0U});
el::io::printLine("Top-level nodes: "_el, document.nodes().count().toSizeT());
el::io::printLine(
"First child is CDATA: "_el, group->children().get(el::ItemIndex{0U})->type() == XmlNodeType::CData);
el::io::printLine("Known text: "_el, group->children().get(el::ItemIndex{0U})->textContentOrThrow());
el::io::printLine("Attribute available: "_el, group->getAttribute("leder"_el).has_value());
el::io::printLine("All text available: "_el, group->textContent().has_value());
try {
auto text = group->textContentOrThrow();
el::io::printLine("Group text: ", text);
} catch (const el::err::NotFoundError &) {
el::io::printLine("Unresolved entity reference"_el);
}
el::io::printLine("DOCTYPE retained: "_el, document.toString().contains("<!DOCTYPE"_el));
}
Output
Top-level nodes: 3
First child is CDATA: true
Known text: fløjte & obo
Attribute available: false
All text available: false
Unresolved entity reference
DOCTYPE retained: true
Limiting Untrusted Input
XmlParseOptions provides four independent limits.
Pass the options as the second argument to either parser form.
A limit violation is a parse failure: fromString() returns no value and fromStringOrThrow() raises
ParseError.
The defaults allow ordinary documents, but a protocol with a known schema can often use smaller limits.
The following example tightens each limit against the same document.
data/DataFormats/XmlExamples.cpp
void limitXmlInput() {
// Each limit rejects the same source for an independent reason.
const auto source = "<orkester><gruppe navn=\"strygere\"><instrument>violin</instrument></gruppe></orkester>"_el;
const auto input = XmlParseOptions{}.setMaximumInputLength(el::ByteCount{10U});
const auto nesting = XmlParseOptions{}.setMaximumNesting(el::ItemCount{2U});
const auto nodes = XmlParseOptions{}.setMaximumNodeCount(el::ItemCount{2U});
const auto strings = XmlParseOptions{}.setMaximumStringLength(el::ByteCount{4U});
el::io::printLine("Input accepted: "_el, XmlDocument::fromString(source, input).has_value());
el::io::printLine("Nesting accepted: "_el, XmlDocument::fromString(source, nesting).has_value());
el::io::printLine("Nodes accepted: "_el, XmlDocument::fromString(source, nodes).has_value());
el::io::printLine("Strings accepted: "_el, XmlDocument::fromString(source, strings).has_value());
}
Output
Input accepted: false
Nesting accepted: false
Nodes accepted: false
Strings accepted: false
Maximum Input Length
setMaximumInputLength(ByteCount{...}) limits the byte length of the complete source String before parsing.
The default is 16 MiB.
The demo’s ten-byte budget rejects the whole document, regardless of the size of any individual value.
Maximum Nesting
setMaximumNesting(ItemCount{...}) limits element depth; the default is 64. The root counts as the first level.
With a limit of two, the demo can enter orkester and gruppe but rejects their nested instrument element.
Maximum Node Count
setMaximumNodeCount(ItemCount{...}) limits all parsed nodes, including elements, text, comments, declarations,
DOCTYPE nodes, processing instructions, and entity references.
The default is 1,000,000. Attributes are held on their element rather than counted as separate nodes.
The demo’s budget of two nodes is exhausted before parsing the nested instrument.
Maximum String Length
setMaximumStringLength(ByteCount{...}) limits each parsed name, text segment, attribute value, and other node
content separately.
The default is 8 MiB.
This is a per-value limit, while maximumInputLength still bounds the complete document.
The demo’s four-byte limit rejects the root name orkester.
Complete demo sources
data/DataFormats/XmlExamples.cpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#include "DataFormatsDemos.hpp"
#include <DemoCommon.hpp>
#include <erbsland/ByteCount.hpp>
#include <erbsland/ItemCount.hpp>
#include <erbsland/ItemIndex.hpp>
#include <erbsland/NotFoundError.hpp>
#include <erbsland/ParseError.hpp>
#include <erbsland/StandardStreams.hpp>
#include <erbsland/xml/XmlDocument.hpp>
#include <erbsland/xml/XmlNode.hpp>
#include <erbsland/xml/XmlNodeType.hpp>
#include <erbsland/xml/XmlParseOptions.hpp>
namespace demo {
using namespace el::text::literals;
using el::xml::XmlDocument;
using el::xml::XmlNode;
using el::xml::XmlNodeType;
using el::xml::XmlParseOptions;
/// Parse a complete XML document and report a malformed one.
///
/// `fromString()` returns an optional document for validation workflows. `fromStringOrThrow()` reports a `ParseError`
/// when the caller needs a diagnostic. Both expect exactly one root element.
void parseXmlDocument() {
// Accept a complete document and read a value from its first child element.
const auto source = "<orkester><gruppe navn=\"strygere\"><instrument>violin</instrument></gruppe></orkester>"_el;
if (const auto document = XmlDocument::fromString(source)) {
el::io::printLine("Root: "_el, document->root()->name());
el::io::printLine(
"Group: "_el, document->root()->children().get(el::ItemIndex{0U})->getAttribute("navn"_el).value());
}
// Use the throwing form when the caller needs a parse diagnostic.
try {
(void)XmlDocument::fromStringOrThrow("<orkester><gruppe></orkester>"_el);
} catch (const el::err::ParseError &) {
el::io::printLine("Invalid XML document"_el);
}
}
/// Build an XML document with nested elements and attributes.
///
/// `addRoot()` establishes the document element, and `addElement()` appends children in order. Attributes are text;
/// `setAttribute()` replaces an existing attribute of the same name without changing its position.
void buildXmlDocument() {
// Append groups and instruments in the order they should appear in XML.
auto document = XmlDocument{};
auto root = document.addRoot("orkester"_el);
root->setAttribute("sted"_el, "Aarhus"_el);
auto group = root->addElement("gruppe"_el);
group->setAttribute("navn"_el, "strygere"_el);
group->addElement("instrument"_el, "violin"_el);
group->addElement("instrument"_el, "cello"_el);
el::io::printLine("Groups: "_el, root->children().count().toSizeT());
el::io::printLine("Instruments: "_el, group->children().count().toSizeT());
}
/// Prepare a document with namespace spelling and ordered markup nodes.
///
/// Namespace declarations are ordinary attributes in this DOM. Factories create special node kinds, and `add()`
/// places them at the intended document or element position. Declaration and DOCTYPE text includes its full markup.
void buildXmlMarkup() {
// Place document-level markup before the root, then append mixed nodes to it.
auto document = XmlDocument{};
document.add(XmlNode::createDeclaration("<?xml version=\"1.0\"?>"_el));
document.add(XmlNode::createDocType("<!DOCTYPE musik:orkester>"_el));
auto root = document.addRoot("musik:orkester"_el);
root->setAttribute("xmlns:musik"_el, "urn:musik"_el);
root->add(XmlNode::createComment(" første sats "_el));
root->add(XmlNode::createProcessingInstruction("spil"_el, " langsomt"_el));
root->add(XmlNode::createCData("violin & bratsch"_el));
const auto xml = document.toString();
el::io::printLine("DOCTYPE written: "_el, xml.contains("<!DOCTYPE musik:orkester>"_el));
el::io::printLine("CDATA written: "_el, xml.contains("<![CDATA[violin & bratsch]]>"_el));
el::io::printLine("Parsed root: "_el, XmlDocument::fromStringOrThrow(xml).root()->name());
}
/// Serialize a document as XML text and parse the result.
///
/// `toString()` escapes ordinary text and attributes. It writes an empty element with `/>` and retains the order of
/// document nodes, children, and attributes, while its choice of quotes and whitespace need not match source text.
void serializeXmlDocument() {
// The writer escapes ordinary text, and the parser restores its value.
auto document = XmlDocument{};
auto root = document.addRoot("instrument"_el, "violin & bratsch"_el);
root->setAttribute("gruppe"_el, "strygere"_el);
const auto xml = document.toString();
el::io::printLine("XML: "_el, xml);
el::io::printLine("Text: "_el, XmlDocument::fromStringOrThrow(xml).root()->textContentOrThrow());
}
/// Inspect ordered XML nodes and their decoded text after parsing.
///
/// `children()` retains mixed content in document order. `getAttribute()` and `textContent()` return optional decoded
/// text, so callers can distinguish an absent or unresolved value from ordinary text.
void inspectXmlDocument() {
// Mixed content retains text and elements as separate ordered children.
const auto source =
"<gruppe navn=\"træblæsere\">Solo: <instrument>fløjte</instrument> og <instrument>obo</instrument></gruppe>"_el;
const auto document = XmlDocument::fromStringOrThrow(source);
const auto root = document.root();
el::io::printLine("Group: "_el, root->getAttribute("navn"_el).value());
el::io::printLine("First child is text: "_el, root->children().get(el::ItemIndex{0U})->type() == XmlNodeType::Text);
el::io::printLine("First instrument: "_el, root->children().get(el::ItemIndex{1U})->textContentOrThrow());
el::io::printLine("All text: "_el, root->textContentOrThrow());
}
/// Preserve XML markup whose entity values are unavailable.
///
/// XML declarations, DOCTYPE declarations, comments, CDATA, instructions, and references remain distinct nodes.
/// External resources and DTD entities are not resolved, so `textContent()` can be empty even for valid XML.
void inspectXmlMarkup() {
// The DTD entity is retained as a reference, including inside the attribute.
const auto source =
"<?xml version=\"1.0\"?><!DOCTYPE orkester [<!ENTITY leder \"Maja\">]>"
"<orkester><gruppe leder=\"&leder;\"><![CDATA[fløjte & obo]]><!-- solo -->&leder;</gruppe></orkester>"_el;
const auto document = XmlDocument::fromStringOrThrow(source);
const auto group = document.root()->children().get(el::ItemIndex{0U});
el::io::printLine("Top-level nodes: "_el, document.nodes().count().toSizeT());
el::io::printLine(
"First child is CDATA: "_el, group->children().get(el::ItemIndex{0U})->type() == XmlNodeType::CData);
el::io::printLine("Known text: "_el, group->children().get(el::ItemIndex{0U})->textContentOrThrow());
el::io::printLine("Attribute available: "_el, group->getAttribute("leder"_el).has_value());
el::io::printLine("All text available: "_el, group->textContent().has_value());
try {
auto text = group->textContentOrThrow();
el::io::printLine("Group text: ", text);
} catch (const el::err::NotFoundError &) {
el::io::printLine("Unresolved entity reference"_el);
}
el::io::printLine("DOCTYPE retained: "_el, document.toString().contains("<!DOCTYPE"_el));
}
/// Apply the four independent XML parser limits to one document.
///
/// The limits cover complete source bytes, element depth, total nodes, and each name or text value. A limit violation
/// rejects the document through either parser form.
void limitXmlInput() {
// Each limit rejects the same source for an independent reason.
const auto source = "<orkester><gruppe navn=\"strygere\"><instrument>violin</instrument></gruppe></orkester>"_el;
const auto input = XmlParseOptions{}.setMaximumInputLength(el::ByteCount{10U});
const auto nesting = XmlParseOptions{}.setMaximumNesting(el::ItemCount{2U});
const auto nodes = XmlParseOptions{}.setMaximumNodeCount(el::ItemCount{2U});
const auto strings = XmlParseOptions{}.setMaximumStringLength(el::ByteCount{4U});
el::io::printLine("Input accepted: "_el, XmlDocument::fromString(source, input).has_value());
el::io::printLine("Nesting accepted: "_el, XmlDocument::fromString(source, nesting).has_value());
el::io::printLine("Nodes accepted: "_el, XmlDocument::fromString(source, nodes).has_value());
el::io::printLine("Strings accepted: "_el, XmlDocument::fromString(source, strings).has_value());
}
}