Reading XML in C Sharp With XDocument and LINQ to XML
Load XML into XDocument, query with LINQ, cast attributes and elements to typed values, handle namespaces, and avoid the culture trap in decimal.Parse.

C# has two XML APIs worth knowing. The older XmlDocument is a DOM in the W3C style, with SelectNodes and XPath. The newer one, LINQ to XML, is built around XDocument and XElement, and you query it with ordinary LINQ. For reading a file into your program, LINQ to XML is shorter and safer, and it is the one to learn first.
Load and query
using System.Globalization;
using System.Xml.Linq;
var doc = XDocument.Load("../library.xml");
decimal usd = 0;
foreach (var b in doc.Root!.Elements("book"))
{
var price = b.Element("price")!;
var amount = decimal.Parse(price.Value, CultureInfo.InvariantCulture);
var note = (string?)b.Element("note");
Console.WriteLine($"{(string?)b.Attribute("id")} | {(string?)b.Element("title")} | {string.Join("; ", b.Elements("author").Select(a => a.Value))} | {amount} {(string?)price.Attribute("currency")} | available={(bool)b.Attribute("available")!} | note={(note is null ? "<absent>" : note == "" ? "<empty>" : note)}");
if ((string?)price.Attribute("currency") == "USD") usd += amount;
}
Console.WriteLine($"USD total {usd}");b1 | Cache Patterns | Ana Lee; Bo Chen | 29.50 USD | available=True | note=Covers R&D
b2 | SQL Tuning, 2nd Edition | Cy Ortiz | 44.00 USD | available=False | note=<absent>
b3 | Indexes "Explained" | Di Park | 250000 VND | available=True | note=<empty>
USD total 73.50Root.Elements("book") returns the book children as a sequence, which you iterate or chain with Where, Select and OrderBy. b.Element("title") finds the first child of that name, and b.Elements("author") all of them, so a book with one author and one with two are the same code. The (string?) cast on an XElement or XAttribute is the central idiom of the API: it returns the text, or null if the element is null. That is why (string?)b.Element("note") can distinguish the cases, giving null for the book with no note, the empty string for <note/> and the text for the first. The output made the three visible. (bool)b.Attribute("available")! casts the attribute text true or false to a boolean, and XML Schema boolean spellings (1, 0) work too.
Because the cast operators are the API, reading code stays short and typed. Casting to int?, decimal?, DateTime? and bool? returns null when the element or attribute is missing:
Console.WriteLine("missing element cast: " + ((int?)doc.Root.Element("nothing") is null ? "null" : "value"));
try { _ = (int)new XElement("q", "2x"); } catch (FormatException e) { Console.WriteLine("cast error: " + e.Message); }missing element cast: null
cast error: The input string '2x' was not in a correct format.A missing value cast to a nullable type is null; text that does not parse raises FormatException with the input in the message. Both behaviors are what you want for a loader: optional fields are nullable, and bad data is an exception that names the value. That is a stricter default than JAXB's (reading XML in Java with JAXB annotations).
The culture trap
One line in the output above deserves attention, and it is the line that would break in production. price.Value is the text 29.50, and I parsed it with CultureInfo.InvariantCulture. Without that argument, decimal.Parse uses the current culture of the thread, and in a German locale the period is the thousands separator:
de-DE parse of '29.50': 2950
invariant parse of '29.50': 29.50Under de-DE, 29.50 parsed as 2950, not as an error, because 29.50 reads as "twenty-nine point five zero thousand" in a locale where the period groups digits. A server configured for German or French reads prices a hundred times too large (or too small) with no exception. XML numbers are written in the invariant format (period as the decimal separator, no grouping), so every numeric parse of XML text must pass CultureInfo.InvariantCulture, including double.Parse, int.Parse and DateTime.Parse. The (decimal)element cast operators of LINQ to XML use XmlConvert under the hood and are culture-independent, so they are safe, and decimal.Parse(element.Value) is the line that is not. Make decimal.Parse without a culture a code review comment.
Namespaces
LINQ to XML names are XName objects that combine a namespace and a local name, and a string converts to an XName in no namespace. A namespaced document therefore returns nothing for a plain string:
var ns = XNamespace.Get("urn:example:library");
var nsDoc = XDocument.Load("../lib_ns.xml");
Console.WriteLine("no ns: " + nsDoc.Root!.Elements("book").Count() + " | with ns: " + nsDoc.Root.Elements(ns + "book").Count());no ns: 0 | with ns: 2ns + "book" builds the qualified name through an operator overload, which is a pleasant way to say it. Declare one XNamespace per vocabulary as a static field and write ns + "name" throughout. The prefix in the file (lib:) never matters; the URI does. Attributes are usually in no namespace even when their element is, so b.Attribute("id") stays unqualified. The behavior is the same as in every other API (how XML namespaces work).
A typed mapping in a few lines
Because the casts do the conversion, a small projection turns the tree into records without a serializer. The shape is a Select that builds an anonymous type or a record:
var books = doc.Root!.Elements("book").Select(b => new {
Id = (string)b.Attribute("id")!,
Title = (string)b.Element("title")!,
Authors = b.Elements("author").Select(a => (string)a).ToList(),
Price = (decimal)b.Element("price")!,
Currency = (string)b.Element("price")!.Attribute("currency")!,
Note = (string?)b.Element("note"),
}).ToList();The non-null assertions (!) say that the field is required: a missing title throws when the cast runs, close to the cause. The Note is nullable because the format allows it to be absent. This is the style I recommend for loaders: required fields are casts without ?, optional fields are casts to nullable types, and a document that breaks the contract fails with an exception that names the input. (The projection is a sketch of the pattern; the run above used the loop form.)
XPath when you want it
LINQ is not the only way. System.Xml.XPath adds XPathSelectElements to XDocument: doc.XPathSelectElements("//book[price/@currency='USD']"). It is handy for paths copied from other tools, and the usual namespace rule applies (an XmlNamespaceManager supplies the prefix). I use LINQ for code and XPath for ad hoc queries copied from a tool.
Large files and safe loading
XDocument.Load reads the entire document, and the tree costs several times the file size. For big files use XmlReader to move to each record and XNode.ReadFrom(reader) to get one XElement at a time:
using var r = XmlReader.Create("orders.xml");
r.MoveToContent();
r.ReadStartElement();
while (r.NodeType == XmlNodeType.Element) { var e = (XElement)XNode.ReadFrom(r); /* use e */ }I did not run this loop here; it is the documented pattern, and the memory behavior is that of XmlReader. Safety settings go on the reader too: XmlReaderSettings with DtdProcessing = DtdProcessing.Prohibit and XmlResolver = null (the defaults in modern .NET are safe for XDocument.Load, but state them for untrusted input).
Related reading
For serializing objects to XML in C# see serializing C# objects to XML, and for the language comparison parsing XML in Java, C# and other typed languages. The API is documented in the LINQ to XML overview, and number parsing in Standard numeric format parsing. Test an XPath in the XPath tester.
When XDocument is the wrong reader for the file
The file is big. Use XmlReader and bind or process one record at a time.
You want typed objects directly. XmlSerializer or a source-generated serializer maps classes to XML, with the caveats in the next article.
You need to preserve everything. XDocument keeps comments and processing instructions when loaded with the right options, and not whitespace unless you ask (LoadOptions.PreserveWhitespace). If exact text matters, know the options.
Performance dominates. Tree building allocates; a forward-only reader is faster and lighter.


