XML Serialization and LINQ to XML
Objective
XML is still what you meet in SOAP services, Office formats, configuration files,
feeds and plenty of legacy integrations. .NET gives you two ways in: serializers
that map objects to XML (XmlSerializer, DataContractSerializer) and LINQ to XML
(XDocument, XElement), a query-and-edit API for XML you do not want to map. This
concept covers what each serializer includes and silently skips, the namespace and
encoding surprises, how to query and modify with LINQ to XML (including a loop that
quietly skips elements), when to stream with XmlReader, and the parser settings that
decide whether a hostile document can read a local file or exhaust memory.
Use Cases
- Reading or producing an XML contract (a feed, an invoice format, a SOAP-era endpoint).
- Pulling a few values out of a large XML file without writing classes for it.
- Editing a configuration file while keeping everything you did not touch.
- Accepting an XML upload from outside your trust boundary.
Deep Dive
XmlSerializer: public shape, attributes, defaults
XmlSerializer maps public read/write properties and public fields to elements. All
output below is from .NET 10.0.0 (SDK 10.0.100). With [XmlRoot], [XmlAttribute],
[XmlElement], [XmlIgnore] and [XmlArray]/[XmlArrayItem] on an Order:
plaintext<?xml version="1.0" encoding="utf-16"?> <order xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" id="7"> <customer>Ana & Co</customer> <Created>2024-01-02T03:04:05Z</Created> <Status>Shipped</Status> <lines><line sku="A"><Qty>2</Qty></line><line sku="B"><Qty>1</Qty></line></lines> </order>
(Indentation shortened.) Things that are easy to miss:
- Two namespace declarations (
xsi,xsd) are added by default. Passing anXmlSerializerNamespacescontaining("", "")removes them. Writing with anXmlWriterandOmitXmlDeclaration = truegave<order id="7"><customer>...</order>. encoding="utf-16"appears because I serialized into aStringWriter, whose encoding is UTF-16. The declaration follows the writer, not your intention;XDocument.Save(StringWriter)did the same. Write to a stream withXmlWriterSettings.Encodingwhen the file must be UTF-8.- A
nullstring (Note) was simply omitted, the[XmlIgnore]property was omitted and came back as'', and an enum was written by name (Shipped). - Only public members: for a class with a public property, an internal one, a private
one and a public field, the output held
FieldandPubonly. - A type with no parameterless constructor fails when you construct the serializer:
InvalidOperationException: NoCtor cannot be serialized because it does not have a parameterless constructor.That is the fast failure; it is not deferred to the firstSerialize. - Polymorphism needs declaring: a
List<Animal>withCatandDogitems worked once I listed[XmlElement("Cat", typeof(Cat)), XmlElement("Dog", typeof(Dog))].
Reading is lenient and loud in different places. For <Line sku='Z' extra='1'> with
an unknown <Bogus> element, deserialization succeeded and silently dropped both; the
UnknownElement event (and UnknownAttribute) is the hook if you want to know. A bad
value, <Qty>abc</Qty>, threw InvalidOperationException: There is an error in XML document (1, 31) with a FormatException inside, and a wrong root element gave
<Other xmlns=''> was not expected. Always read the inner exception and the line and
position.
DataContractSerializer: opt-in members
DataContractSerializer is the WCF-era alternative with the opposite default: only
members marked [DataMember] are written, and a property without the attribute is
skipped. For [DataContract] class Dc { [DataMember] A; [DataMember(Order = 2)] B; int NotMember } the output was
<Dc xmlns="http://schemas.datacontract.org/2004/07/" xmlns:i="..."><A>1</A><B>x</B></Dc>.
Note the namespace derived from the CLR namespace. The security guide warns about a different class, NetDataContractSerializer, which does
unrestricted polymorphic deserialization: do not confuse the two.
LINQ to XML: query without classes
XDocument and XElement are an in-memory tree. The docs describe it as the option
to use for new code. The one concept to learn is namespaces: a name is XNamespace + local name, and a query without the namespace finds nothing.
csharpXNamespace s = "urn:shop";
var q = doc.Root!.Elements(s + "order").Select(o => new {
Id = (int)o.Attribute("id")!,
Customer = (string)o.Element(s + "customer")!,
Total = o.Elements(s + "line").Sum(x => (int)x.Attribute("qty")! * (decimal)x.Attribute("price")!) });
// order 1 Ana total 22.0 order 2 Bo total 47.5On the same document doc.Root.Elements("order") counted 0 because the elements are
in urn:shop. The explicit casts are the reading API: (string?) on a missing
element is null, but (int) on a missing attribute threw ArgumentNullException
and (int) on the text Ana threw FormatException. Cast to int? when the value
may be absent. XPath works through System.Xml.XPath: XPathSelectElements with an
XmlNamespaceManager mapping a prefix to urn:shop found the open order.
Editing is direct: SetAttributeValue("status", "shipped"), Add(new XElement(...)),
Remove(). The trap is removing while enumerating. On <r><a/><a/><a/><a/></r>,
foreach (var a in root.Elements("a")) a.Remove(); left 3 of 4 elements, because
removing a node disturbs the lazy enumeration. Materialize first,
foreach (var a in root.Elements("a").ToList()) a.Remove(); left 0, and so did the
extension root.Elements("a").Remove(), which does the copying for you.
Streaming big files with XmlReader
XDocument loads everything. For a large file, XmlReader plus XNode.ReadFrom
gives you one small XElement at a time:
csharpwhile (!rd.EOF)
if (rd.NodeType == XmlNodeType.Element && rd.Name == "item") { var el = (XElement)XNode.ReadFrom(rd); /* use el */ }
else rd.Read();
// [0:0] [1:1] [2:4] [3:9] [4:16]ReadFrom leaves the reader after the element, so the else Read() branch must not
skip the next one. That is the standard pattern, and it keeps memory proportional to a
single item.
Parser settings are a security boundary
An XML document can declare entities, and an entity can point at a file or a URL
(XXE), or expand exponentially. What happens depends on which entry point you use. I
tested a document whose DTD defines &x; as file:///tmp/csf-secret.txt:
| Entry point | Result |
|---|---|
XmlReader.Create with default settings |
XmlException: For security reasons DTD is prohibited (default is DtdProcessing.Prohibit, per the docs) |
XDocument.Parse(string) and XmlDocument.LoadXml |
parsed, entity left empty (no resolver, file not read) |
XmlSerializer.Deserialize |
InvalidOperationException |
DtdProcessing.Parse only |
parsed, entity empty |
DtdProcessing.Parse plus XmlResolver = new XmlUrlResolver() |
read the file: TOP-SECRET-CONTENT |
So file disclosure took two deliberate opt-ins in .NET 10. Entity expansion is the
other axis. A small "billion laughs" document (four nested levels of 10 expansions, 10^4 copies of lol) expanded to 30000
characters through XDocument.Parse without complaint, and the same shape with 8
levels (715 characters in, 300 million out if expanded) threw XmlException: The input document has exceeded a limit set by MaxCharactersFromEntities from both
XDocument.Parse and XmlDocument.LoadXml. On a fresh XmlReaderSettings with Parse
the property read 10000000, although the docs give the default as 0, so set it yourself
rather than relying on either. Setting it to 1000 made the 30000-character document
fail. The safe pattern for outside input:
csharpvar s = new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null,
MaxCharactersInDocument = 10_000_000 };
var doc = XDocument.Load(XmlReader.Create(stream, s));I did not measure a document-size limit's effect. Whatever the version, keep DTDs
prohibited unless you control the document, and never pass an XmlUrlResolver for
untrusted input.
Trade-offs
XmlSerializeris convention over contract. Everything public is included, which is convenient and leaks new properties into the wire format.csharp[XmlIgnore] public string Secret { get; set; } = ""; // opt out explicitlyDataContractSerializeris explicit and invents a namespace. Safer defaults for members, more ceremony, a namespace you may not want.- LINQ to XML is dynamic and unchecked. No compile-time schema; missing nodes
become
nullor exceptions depending on the cast. - Lazy queries plus mutation is a bug generator. Materialize before you remove.
XDocumentis memory-bound. Stream large inputs withXmlReaderand parse one element at a time.- Defaults are not a policy. Set
DtdProcessing,XmlResolverand the entity limit explicitly where input crosses a trust boundary.