Mert Özen Further With Every Line
Backend I/O in .NET — #06

Working with XML: XmlReader, LINQ to XML, XmlSerializer and Secure Defaults

Mert Özen Nov 14, 2026 14 min 2 views
Working with XML: XmlReader, LINQ to XML, XmlSerializer and Secure Defaults

Hybrid reading, where XmlReader walks a large XML file and turns each document into a short-lived XElement, combines the convenience of LINQ to XML with constant memory: on a 267 MB file XDocument used a 703 MB heap, the hybrid approach 8.5 MB. With a UBL-like Bookstore invoice we cover namespaces, XSD validation, XmlSerializer versus DataContractSerializer, XmlSerializer's uncached constructors and .NET's defaults against XXE.

Version note: The code in this post was built and run with .NET 10.

Guess First

Bookstore, the online bookstore used as the shared example across this site's series, produces its invoices as UBL-like XML. Someone on the accounting team writes this to count the invoice lines:

int lines = invoice.Root!.Elements("InvoiceLine").Count();

The invoice has three lines. What does this code return?

Second question: the XML sent to the shipping company needs a different root element name, so every request calls new XmlSerializer(typeof(ShipmentNotice), new XmlRootAttribute("Notice")). After 200 requests, how many new assemblies does the process have?

Third: if you read an XML containing a <!DOCTYPE> declaration with XmlReader.Create, XDocument.Parse and XmlDocument.LoadXml, do all three behave the same way?

We covered JSON in two posts; XML is still part of everyday work through e-invoicing, bank integrations, shipping and old SOAP services. The examples are in the Bookstore.IO.Xml console app in the series' repo:

dotnet run --project src/Bookstore.IO.Xml -- read

Which API When

There are five main ways to work with XML in .NET, each designed for a different need:

APIModelWhen
XmlReader / XmlWriterForward-only, streaming, async capableLarge files, constant memory, lowest allocation
XDocument / XElement (LINQ to XML)Mutable in-memory treeProducing, reading and querying a single document
XmlDocumentOld DOM, XPath-centricMaintaining existing code; XDocument for new code
XmlSerializerXML from public properties, full control through attributesIntegrations where the format is imposed from outside
DataContractSerializerOpt-in contract, elements onlyContracts coming from the WCF world

In practice the most used pair is XmlReader and LINQ to XML. Shortly we will see a hybrid approach that uses both together.

LINQ to XML: A UBL-Like Invoice and Namespaces

In Türkiye, e-invoicing uses UBL-TR, the format the Turkish Revenue Administration, GİB, built on top of UBL 2.1. In a real integration you need to take the schema and its version from GİB's current package; in this post we work with a schema of our own that uses the real UBL 2.1 namespaces but a simplified element set. The goal is not to teach e-invoicing rules but to show how to work with a namespace-heavy document in .NET.

In UBL, elements are spread across three namespaces: the document root (Invoice-2), aggregate components (cac) and basic components (cbc). In LINQ to XML a namespace is an XNamespace object, and an element name is built as namespace + "LocalName":

public static class Ubl
{
    public static readonly XNamespace Invoice = "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2";
    public static readonly XNamespace Cac = "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2";
    public static readonly XNamespace Cbc = "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2";
}

public static XDocument Create(Order order, DateOnly issueDate)
{
    XNamespace cac = Ubl.Cac, cbc = Ubl.Cbc;
    return new XDocument(
        new XDeclaration("1.0", "utf-8", null),
        new XElement(Ubl.Invoice + "Invoice",
            new XAttribute(XNamespace.Xmlns + "cac", cac),
            new XAttribute(XNamespace.Xmlns + "cbc", cbc),
            new XElement(cbc + "UBLVersionID", "2.1"),
            new XElement(cbc + "ID", order.OrderNumber.Replace("BS-", "BSI2026")),
            new XElement(cbc + "IssueDate", issueDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)),
            new XElement(cbc + "DocumentCurrencyCode", order.Currency),
            new XElement(cac + "AccountingCustomerParty",
                new XElement(cac + "Party",
                    new XElement(cac + "PartyName",
                        new XElement(cbc + "Name", order.CustomerName)))),
            order.Items.Select((item, index) =>
                new XElement(cac + "InvoiceLine",
                    new XElement(cbc + "ID", index + 1),
                    new XElement(cbc + "InvoicedQuantity", new XAttribute("unitCode", "C62"), item.Quantity),
                    new XElement(cbc + "LineExtensionAmount", new XAttribute("currencyID", order.Currency), Amount(item.LineTotal)),
                    new XElement(cac + "Item",
                        new XElement(cbc + "Name", item.Title),
                        new XElement(cac + "StandardItemIdentification",
                            new XElement(cbc + "ID", item.Isbn))),
                    new XElement(cac + "Price",
                        new XElement(cbc + "PriceAmount", new XAttribute("currencyID", order.Currency), Amount(item.UnitPrice))))),
            new XElement(cac + "LegalMonetaryTotal",
                new XElement(cbc + "PayableAmount", new XAttribute("currencyID", order.Currency), Amount(order.Total)))));
}
<?xml version="1.0" encoding="utf-8"?>
<Invoice xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
  <cbc:UBLVersionID>2.1</cbc:UBLVersionID>
  <cbc:ID>BSI20260000003</cbc:ID>
  <cbc:IssueDate>2026-11-14</cbc:IssueDate>
  <cbc:DocumentCurrencyCode>TRY</cbc:DocumentCurrencyCode>
  <cac:AccountingCustomerParty>
    <cac:Party>
      <cac:PartyName>
        <cbc:Name>Linus</cbc:Name>
      </cac:PartyName>
    </cac:Party>
  </cac:AccountingCustomerParty>
  <cac:InvoiceLine>
    <cbc:ID>1</cbc:ID>
    <cbc:InvoicedQuantity unitCode="C62">2</cbc:InvoicedQuantity>
    <cbc:LineExtensionAmount currencyID="TRY">299.80</cbc:LineExtensionAmount>
    <cac:Item>
      <cbc:Name>Frankenstein</cbc:Name>
      <cac:StandardItemIdentification>
        <cbc:ID>9780141439471</cbc:ID>
      </cac:StandardItemIdentification>
    </cac:Item>
    <cac:Price>
      <cbc:PriceAmount currencyID="TRY">149.90</cbc:PriceAmount>
    </cac:Price>
  </cac:InvoiceLine>
  ...
  <cac:LegalMonetaryTotal>
    <cbc:PayableAmount currencyID="TRY">996.30</cbc:PayableAmount>
  </cac:LegalMonetaryTotal>
</Invoice>

The shape of the code matches the shape of the document; that is the strongest side of LINQ to XML. Without the xmlns:cac and xmlns:cbc attributes the document would still be correct, but .NET would generate the prefixes itself (p1, p2 and so on). Declaring the prefixes the other side expects is a good habit, both for readability and for badly written parsers that look at prefixes. C62 means "unit" in the UN/ECE unit codes. Amounts are again written with InvariantCulture; in XML the decimal separator is always a dot.

Now the first question. Searching a namespaced document for an element by its local name alone finds nothing:

Console.WriteLine($"Without namespace: {invoice.Root!.Elements("InvoiceLine").Count()} lines");
Console.WriteLine($"With namespace:    {invoice.Root!.Elements(Ubl.Cac + "InvoiceLine").Count()} lines");
Without namespace: 0 lines
With namespace:    3 lines
  9780141439471 Frankenstein           x2 = 299.80 TRY
  9780141439587 Emma                   x1 = 189.50 TRY
  9780141439686 Persuasion             x3 = 507.00 TRY

When the string "InvoiceLine" is converted to an XName, it becomes a name without a namespace and does not match cac:InvoiceLine. No exception, just an empty result; a sneaky bug that passes tests with an empty list and makes invoices look like they have zero lines in production. The code that reads the lines gets values from elements with explicit casts:

return invoice.Root!
    .Elements(cac + "InvoiceLine")
    .Select(line => new InvoiceLineInfo(
        Isbn: (string)line.Element(cac + "Item")!.Element(cac + "StandardItemIdentification")!.Element(cbc + "ID")!,
        Title: (string)line.Element(cac + "Item")!.Element(cbc + "Name")!,
        Quantity: (decimal)line.Element(cbc + "InvoicedQuantity")!,
        LineTotal: (decimal)line.Element(cbc + "LineExtensionAmount")!,
        Currency: (string)line.Element(cbc + "LineExtensionAmount")!.Attribute("currencyID")!));

The (decimal)element conversion parses according to XML Schema rules, independent of culture; it is safer than writing decimal.Parse(element.Value). Attributes usually have no namespace; we look up currencyID by its bare name.

Validating with XSD

Checking that an incoming invoice matches the schema before processing it catches a broken document at the door rather than deep inside the business logic. We have three XSD files for the three namespaces; we add them all to an XmlSchemaSet and validate while reading:

public static List<string> Validate(TextReader xml, XmlSchemaSet schemas)
{
    var errors = new List<string>();
    var settings = new XmlReaderSettings
    {
        ValidationType = ValidationType.Schema,
        Schemas = schemas,
        DtdProcessing = DtdProcessing.Prohibit,
        XmlResolver = null
    };
    settings.ValidationEventHandler += (_, e) =>
        errors.Add($"{e.Severity} line {e.Exception.LineNumber}: {e.Message}");

    using var reader = XmlReader.Create(xml, settings);
    while (reader.Read()) { }
    return errors;
}
Valid invoice: 0 errors
Missing ID: 1 errors
  Error line 3: The element 'Invoice' in namespace 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2' has invalid child element 'IssueDate' in namespace 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2'. List of possible elements expected: 'ID' in namespace 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2'.
Bad values: 2 errors
  Error line 15: The 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2:InvoicedQuantity' element is invalid - The value 'two' is invalid according to its datatype 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2:QuantityType' - The string 'two' is not a valid Decimal value.
  Error line 56: The required attribute 'currencyID' is missing.

Because we pass a ValidationEventHandler, the reader does not stop at the first error and we collect them all; without a handler you would get an XmlSchemaValidationException on the first error. Line numbers let you give a partner clear feedback, such as "the quantity on line 15 of your invoice is not a number".

One detail to note: in the first attempt we had deliberately broken the document in three places, but only one error came back. Once an ordering error appears (IssueDate arriving where ID was expected), the validator cannot reliably validate the rest of that element's content and does not report the later errors. That is why we split it in two. Zero errors means "the document is entirely correct"; one error means "there is at least one error".

Large XML: Reading Element by Element with XmlReader

At month end, all invoices are collected in a single file: 200,000 invoices, 267 MB. To write it, we open XmlWriter in async mode; each invoice is built as a small XElement and written to the file right away:

var settings = new XmlWriterSettings { Async = true, Indent = false, Encoding = new System.Text.UTF8Encoding(false) };
await using XmlWriter writer = XmlWriter.Create(destination, settings);
await writer.WriteStartDocumentAsync();
await writer.WriteStartElementAsync(null, "InvoiceBatch", "urn:bookstore:invoices:batch");
await writer.WriteAttributeStringAsync("xmlns", "cac", null, Ubl.Cac.NamespaceName);
await writer.WriteAttributeStringAsync("xmlns", "cbc", null, Ubl.Cbc.NamespaceName);
foreach (var order in orders)
{
    // Each invoice is built as a small XElement and written straight away.
    XElement invoice = InvoiceXml.Create(order, issueDate).Root!;
    invoice.Attributes().Where(a => a.IsNamespaceDeclaration && a.Name.Namespace == XNamespace.Xmlns).Remove(); // cac/cbc are declared once on the batch root
    await invoice.WriteToAsync(writer, CancellationToken.None);
}
await writer.WriteEndElementAsync();
await writer.WriteEndDocumentAsync();

We declare the cac and cbc prefixes once on the batch root and remove the repeats from each invoice; in the first attempt that repetition added 33 MB to the file. On the read side we compared two approaches. One loads the whole file with XDocument.Load; the other is the hybrid approach: XmlReader walks through the file, and whenever it reaches an Invoice element, it turns only that element into a short-lived XElement with XNode.ReadFromAsync:

var settings = new XmlReaderSettings { Async = true, DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null };
using XmlReader reader = XmlReader.Create(source, settings);
XName payable = Ubl.Cbc + "PayableAmount";
int count = 0;
decimal total = 0;

await reader.MoveToContentAsync();
while (!reader.EOF)
{
    if (reader.NodeType == XmlNodeType.Element && reader.LocalName == "Invoice" && reader.NamespaceURI == Ubl.Invoice.NamespaceName)
    {
        // ReadFrom moves the reader past the element, so no extra Read() here.
        var invoice = (XElement)await XNode.ReadFromAsync(reader, CancellationToken.None);
        total += (decimal)invoice.Descendants(payable).Single();
        count++;
    }
    else
    {
        await reader.ReadAsync();
    }
}
write     200,000 invoices in 1,996 ms, peak heap 4,136,688 bytes
stream    200,000 invoices, 177,944,049.50 TRY, file 267,026,162 bytes, 2,200 ms, peak heap 8,564,056 bytes
xdocument 200,000 invoices, 177,944,049.50 TRY, file 267,026,162 bytes, 1,928 ms, peak heap 703,209,472 bytes

The times are close, but memory use differs by a factor of 80: XDocument used a 703 MB heap for the 267 MB file, the hybrid approach 8.5 MB. We keep the convenience of LINQ to XML inside each invoice while never loading the whole file into memory. The small but important detail in the loop is in the comment: ReadFrom moves the reader past the element, so if you also call Read in that branch you skip the next invoice. This is the XML counterpart of the constant memory principle we saw with DeserializeAsyncEnumerable on the JSON side.

XmlSerializer and DataContractSerializer

The shipping company wants the shipment notice in a specific XML shape: the order number as an attribute, the parcels in a Parcels list, the weight as an attribute and the ISBN as the element's text. We marked the same class for both serializers:

[XmlRoot("ShipmentNotice", Namespace = "urn:bookstore:shipping")]
[DataContract(Name = "ShipmentNotice", Namespace = "urn:bookstore:shipping")]
public sealed class ShipmentNotice
{
    [XmlAttribute("orderNumber")]
    [DataMember(Order = 1)]
    public string OrderNumber { get; set; } = "";

    [XmlElement("Carrier")]
    [DataMember(Order = 2)]
    public string Carrier { get; set; } = "";

    [XmlElement("TrackingCode")]
    [DataMember(Order = 3)]
    public string TrackingCode { get; set; } = "";

    [XmlArray("Parcels"), XmlArrayItem("Parcel")]
    [DataMember(Order = 4)]
    public List<Parcel> Parcels { get; set; } = [];

    [XmlIgnore]
    public decimal InternalCost { get; set; }
}

[DataContract(Namespace = "urn:bookstore:shipping")]
public sealed class Parcel
{
    [XmlAttribute("weightKg")]
    [DataMember(Order = 1)]
    public decimal WeightKg { get; set; }

    [XmlText]
    [DataMember(Order = 2)]
    public string Isbn { get; set; } = "";
}
XmlSerializer:
<ShipmentNotice xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" orderNumber="BS-0000042" xmlns="urn:bookstore:shipping">
  <Carrier>ACME Cargo</Carrier>
  <TrackingCode>TRK-123456</TrackingCode>
  <Parcels>
    <Parcel weightKg="0.8">9780441172719</Parcel>
    <Parcel weightKg="0.5">9780141439587</Parcel>
  </Parcels>
</ShipmentNotice>
DataContractSerializer:
<ShipmentNotice xmlns:i="http://www.w3.org/2001/XMLSchema-instance" xmlns="urn:bookstore:shipping">
  <OrderNumber>BS-0000042</OrderNumber>
  <Carrier>ACME Cargo</Carrier>
  <TrackingCode>TRK-123456</TrackingCode>
  <Parcels>
    <Parcel>
      <WeightKg>0.8</WeightKg>
      <Isbn>9780441172719</Isbn>
    </Parcel>
    ...
  </Parcels>
</ShipmentNotice>

The difference is clear. XmlSerializer gives you full control over the shape of the XML: attributes, elements, text, list wrappers, namespaces. In return it needs a public parameterless constructor and public, settable properties. DataContractSerializer produces no attributes, everything is an element; you choose what gets serialized explicitly with [DataMember] (InternalCost stayed out because it is not marked), and without Order it sorts elements alphabetically. If the other side imposes the format, use XmlSerializer; if both ends are your code and there is no contract left over from WCF, JSON is often a better choice than XML. If you do not want the xsi and xsd declarations XmlSerializer adds to every output, pass an empty XmlSerializerNamespaces to Serialize.

Constructors that are not cached

The answer to the second question. On first use, XmlSerializer generates serialization code for the type at runtime and compiles it into a dynamic assembly. The XmlSerializer(Type) and XmlSerializer(Type, string) constructors cache that assembly; the other overloads do not, and generate a new one on every call:

for (int i = 0; i < 200; i++)
{
    new XmlSerializer(typeof(ShipmentNotice)).Serialize(TextWriter.Null, notice);
}

var root = new XmlRootAttribute("Notice") { Namespace = "urn:bookstore:shipping" };
for (int i = 0; i < 200; i++)
{
    new XmlSerializer(typeof(ShipmentNotice), root).Serialize(TextWriter.Null, notice);
}
Assemblies at start:                          10
After 200 x XmlSerializer(Type):              24
After 200 x XmlSerializer(Type, XmlRootAttribute): 224 (191 ms)
Dynamic assemblies: 201

The first loop generated code only once (the other increase is System.Xml's own assemblies). The second loop generated a new dynamic assembly on every request: 200 assemblies for 200 requests. These assemblies cannot be unloaded; in a service that runs all day, memory keeps growing, and every request also pays about 1 ms of extra compilation. The fix is simple: create a serializer built with these overloads once and keep it in a static field or in a ConcurrentDictionary keyed by type and root name. XmlSerializer instances are thread-safe, so sharing them is safe.

Secure Defaults: DTD, XmlResolver and XXE

XML's DTD feature allows entity definitions that refer to external resources. An XXE attack uses this to make the server read its own files or send requests into the internal network; nesting entities to blow them up can create a DoS that exhausts memory. The details and examples of the attack are in the two posts of the OWASP Top 10 series that cover XXE (A02 Security Misconfiguration and the post looking at the 2017 list); here we only look at .NET's defaults. We read a document with a harmless internal entity, referring to no external resource, with three APIs:

// A harmless internal DTD: no external references at all.
const string withDtd = """
    <?xml version="1.0"?>
    <!DOCTYPE ShipmentNotice [ <!ENTITY carrier "ACME Cargo"> ]>
    <ShipmentNotice><Carrier>&carrier;</Carrier></ShipmentNotice>
    """;
XmlReaderSettings defaults: DtdProcessing=Prohibit
XmlReader.Create (default): XmlException: For security reasons DTD is prohibited in this XML document. To enable DTD processing set the DtdProcessing property on XmlReaderSettings to Parse and pass the settings into XmlReader.Create method.
XDocument.Parse: ACME Cargo
XmlDocument.LoadXml: ACME Cargo
MaxCharactersFromEntities default: 10,000,000

The answer to the third question: no. The default for XmlReader.Create is to reject DTDs outright. XDocument and XmlDocument, however, process the DTD and expand the internal entity. What keeps them safe is that in .NET (Core) and .NET 10 the default XmlResolver does not resolve external resources: on our own machine we separately verified that an entity referring to an external file comes back empty in both APIs. The number of characters coming from entity expansion is also limited to 10 million by default. But that safety disappears with a single line: adding XmlResolver = new XmlUrlResolver(), or turning on DtdProcessing.Parse together with a resolver, makes external entities resolvable again.

The real risk is in old code. In .NET Framework versions before 4.5.2, the default resolver of XmlDocument resolved external resources, and old APIs such as XmlTextReader processed DTDs by default. After you move that code to .NET 10, explicitly written XmlResolver assignments stay as they are. The rule: always read external XML through an XmlReader with DtdProcessing = DtdProcessing.Prohibit and XmlResolver = null; the XDocument.Load(XmlReader) and XmlDocument.Load(XmlReader) overloads make that possible. UBL documents define their structure with XSD and do not use DTDs; an invoice that contains a DTD is suspicious to begin with.

In Production

Leave the encoding to the document. Reading an XML file into a string with File.ReadAllText and parsing it afterwards skips the document's own encoding declaration; Turkish characters break in an old bank file written in Windows-1254. Hand the stream straight to XmlReader and let it work out the encoding from the XML declaration and the BOM.

Set size limits. For XML coming from users, use the request body limit, XmlReaderSettings.MaxCharactersInDocument and, if needed, a depth check. Hybrid reading protects memory, but not against trying to process a file of unlimited size.

Load the schema once. Compiling an XmlSchemaSet is expensive; instead of reloading it on every request, compile it once at startup and share it.

Classify validation errors. For documents from a partner, a schema error means "send it back", while a business rule error (the total not matching the sum of the lines, for example) means "investigate". Logging both with the same exception makes it hard to see which one is growing.

Common Pitfalls

Forgetting the namespace. Elements("InvoiceLine") silently returns nothing on a namespaced document. Define XNamespace constants and use them in every query.

Creating XmlSerializer with XmlRootAttribute or XmlAttributeOverrides without caching it. Every call produces a new assembly that cannot be unloaded. Create it once, keep it.

Turning on XmlResolver because "it does not work". Code that enables a resolver to find a file a schema imports will also resolve external entities in user XML that passes through the same reader. Add schemas to an XmlSchemaSet one by one, as we did; keep the resolver null on any reader that reads user XML.

Opening a large file with XDocument.Load. The 267 MB file meant a 703 MB heap. Use hybrid reading for any file that contains more than one document.

Parsing decimals with the culture. decimal.Parse(element.Value) misreads 299.80 under Turkish culture; use an explicit cast or XmlConvert.ToDecimal.

A Small Experiment

★☆☆ Change the read command to find the lines with XPath instead of Descendants(cac + "InvoiceLine") (XPathSelectElements and an XmlNamespaceManager). What happens if you do not register the prefixes in the namespace manager?

★★☆ Since you cannot add a business rule to the schema, add it in code: after validation, write a step that checks that the sum of the LineExtensionAmounts equals PayableAmount, and report the difference with a line number. Hint: LoadOptions.SetLineInfo and IXmlLineInfo.

★★★ Write a thread-safe cache for XmlSerializer keyed by type and root name, and run the second loop of the serializer-cache command again with it. How many dynamic assemblies are there now?

That wraps up XML. The next post moves to another everyday format: CSV. We will look at what the RFC 4180 standard does and does not define, Turkish Excel's habits of semicolons and decimal commas, old bank files arriving in Windows-1254, and CSV injection.