JSON gets all the attention, but I still end up writing XML readers every few months: a bank statement format, a SOAP response from a partner, an RSS feed, a vendor’s inventory export. In .NET the least painful option is usually XmlSerializer with a set of attributed classes. Writing those classes is the tedious part, and the part where silent bugs creep in — XmlSerializer doesn’t throw when a name or namespace is wrong, it just leaves the property null.
This is how I use the XML to C# generator to get the classes right the first time.
From a sample to classes
Take a small order document:
<order id="1042" xmlns="urn:shop">
<placed>2026-09-20T14:05:00Z</placed>
<customer vip="true">Asha Rao</customer>
<lines>
<line sku="KB-104" qty="2">4599.00</line>
<line sku="MS-220" qty="1">1299.50</line>
</lines>
</order>
The generator produces:
[XmlRoot("order", Namespace = "urn:shop")]
public class Order
{
[XmlAttribute("id")]
public int Id { get; set; }
[XmlElement("placed")]
public DateTime Placed { get; set; }
[XmlElement("customer")]
public Customer? Customer { get; set; }
[XmlArray("lines")]
[XmlArrayItem("line")]
public List<Line> Lines { get; set; } = new();
}
[XmlType(Namespace = "urn:shop")]
public class Customer
{
[XmlAttribute("vip")]
public bool Vip { get; set; }
[XmlText]
public string? Value { get; set; }
}
[XmlType(Namespace = "urn:shop")]
public class Line
{
[XmlAttribute("sku")]
public string? Sku { get; set; }
[XmlAttribute("qty")]
public int Qty { get; set; }
[XmlText]
public decimal Value { get; set; }
}
Reading it is then two lines:
var serializer = new XmlSerializer(typeof(Order));
var order = (Order)serializer.Deserialize(File.OpenRead("order.xml"))!;
The things the attributes are doing
Names. XML is case-sensitive and usually camelCase or lowercase; C# properties are PascalCase. [XmlElement("placed")] maps one to the other. Without it, XmlSerializer looks for <Placed> and never finds it.
Namespaces. This one bites everyone. The root declares xmlns="urn:shop", which puts every element in that namespace. If the class doesn’t say Namespace = "urn:shop", deserialization “works” and returns an object full of nulls. The generator writes [XmlRoot(Namespace = …)] for the root and [XmlType(Namespace = …)] for other classes, and adds Namespace = … to any element or attribute whose namespace differs from its parent — which is what SOAP envelopes and Atom links need.
Attributes plus text. <customer vip="true">Asha Rao</customer> has an attribute and text content, so it becomes a class with an [XmlAttribute] and an [XmlText] property.
Wrapper lists. <lines> contains only repeated <line> elements, which is the pattern [XmlArray] + [XmlArrayItem] describes. If the repeated elements sit directly under the parent without a wrapper, you get [XmlElement("line")] List<Line> instead.
One sample can’t prove a list
The hardest part of generating classes from a sample is that the sample might be unrepresentative. If your test file has one <line>, nothing proves it’s a list — and in production the second order with two lines deserializes with only the first. The generator lists every element that appeared once and lets you mark any of them as a list. I tick anything whose name is plural-ish or that the schema documentation says repeats.
The same caution applies to optional elements. If an element is missing from some records in the sample, value types become nullable (DateTime?), so a missing <discontinuedOn> doesn’t become 0001-01-01. Attributes are the exception: XmlSerializer can’t serialize Nullable<T> as an attribute. The generator leaves a comment pointing at the XxxSpecified pattern — a bool property named after the attribute plus Specified — which is how XmlSerializer tracks optional attributes.
Types come from every value
Types are inferred from all the values seen for an element or attribute, not just the first. id="1" and id="3000000000" widen to long; 2 and 2.5 widen to decimal; one non-numeric value makes it a string. Values with leading zeros, like postcodes (02134), stay strings, because converting them to int would lose data. If you’d rather decide yourself, turn inference off and every value is a string.
Things XmlSerializer won’t do
- Mixed content such as
<p>Hello <b>world</b></p>:[XmlText]captures the text, but not where it sits between the elements. The generator warns you; for documents like that,XDocumentis the better tool. - DTDs:
XmlSerializerdisables DTD processing by default, and so does the generator — it never fetches external entities. A document that relies on entities like defined in a DTD won’t parse; that’s intentional. - Private setters and read-only collections:
XmlSerializerneeds public getters and setters and a public parameterless constructor.
When to use something else
XmlSerializer is great for known, stable formats. For very large files, stream with XmlReader. For one-off queries into a document, XDocument with LINQ is quicker to write. For JSON, JSON to C# does the same job for System.Text.Json.
For everything in between — a feed, a partner’s SOAP response, an export you need to load into a database — paste a representative sample into the XML to C# generator, tick the elements that should be lists, and you’ll have classes that deserialize on the first run.