Skip to content
datahacks.dev
XML

Serializing C Sharp Objects to XML Without the Extra Noise

XmlSerializer writes xsi and xsd namespace declarations and a utf-16 header by default. Remove them, control attributes and lists, and see what it ignores.

6 min read
Summarize in AI
Flat illustration of paired angle-bracket tags wrapped around a document, with the XML logo.

If your program owns the data as objects and a partner wants XML, you serialize. .NET has had XmlSerializer since the first release, and it works, with a few defaults that produce output you probably did not intend. This article shows the output, the settings that clean it up and what deserializing does with data it does not recognize.

The class and the first output

C#
[XmlRoot("Book")]
public class Book
{
    [XmlAttribute("id")] public string Id { get; set; } = "";
    public string Title { get; set; } = "";
    [XmlElement("Author")] public List<string> Authors { get; set; } = new();
    public bool Available { get; set; }
    public string? Note { get; set; }
}
C#
var s = new XmlSerializer(typeof(Book));
var w = new StringWriter();
s.Serialize(w, new Book { Id = "b9", Title = "Q & A", Authors = { "X", "Y" }, Available = true, Note = null });
Console.WriteLine(w);
XML
<?xml version="1.0" encoding="utf-16"?>
<Book xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" id="b9">
  <Title>Q &amp; A</Title>
  <Author>X</Author>
  <Author>Y</Author>
  <Available>true</Available>
</Book>

Look at what the attributes did. [XmlAttribute("id")] made Id an attribute named id. [XmlElement("Author")] on a list wrote each entry as an Author element and added no wrapper, so the list is repeated siblings; to get <Authors><Author>..</Author></Authors> you use [XmlArray("Authors")] with [XmlArrayItem("Author")]. The title's ampersand was escaped correctly. The boolean is true in lower case, which is the XML Schema spelling, not .NET's True. And Note, which was null, produced no element, so null means absent in the output. If a consumer needs <Note/> or xsi:nil, say so with [XmlElement(IsNullable = true)], which writes xsi:nil="true".

Two things are noise. The root carries xmlns:xsi and xmlns:xsd declarations that nothing uses. And the declaration says encoding="utf-16".

Removing the namespace declarations

The two declarations come from the serializer's default namespaces. An XmlSerializerNamespaces object with a single empty entry replaces them:

C#
var empty = new XmlSerializerNamespaces();
empty.Add("", "");
var w2 = new StringWriter();
s.Serialize(w2, new Book { Id = "b9", Title = "Q & A", Available = false }, empty);
XML
<?xml version="1.0" encoding="utf-16"?>
<Book id="b9">
  <Title>Q &amp; A</Title>
  <Available>false</Available>
</Book>

The root is clean. If your document does use a namespace (a SOAP envelope, a vendor vocabulary), add it to the same object with its prefix: ns.Add("m", "urn:shop"), and set [XmlRoot(Namespace = "urn:shop")] so the elements are qualified. The xsi declaration reappears automatically when something needs it, such as a nullable element with IsNullable.

The encoding

The utf-16 in the declaration is a consequence of the writer, not the serializer. A StringWriter holds .NET strings, which are UTF-16, so the serializer reports that. If you write the string to a file as UTF-8, you now have a UTF-8 file that declares UTF-16, and a strict parser reads it wrong or rejects it (encoding mismatch errors in XML documents). I did not test a consumer with this, but the mismatch is the known failure.

The fix is to serialize to a stream through an XmlWriter with the encoding you want:

C#
var settings = new XmlWriterSettings { Encoding = new UTF8Encoding(false), Indent = true };
using var stream = File.Create("book.xml");
using var writer = XmlWriter.Create(stream, settings);
s.Serialize(writer, book, empty);

new UTF8Encoding(false) means UTF-8 without a byte order mark, which most consumers prefer. The declaration then says utf-8 and the bytes agree. Prefer writing to a stream or a file over building a string and encoding it afterwards.

What Deserialize does with what it doesn't know

C#
var back = (Book)s.Deserialize(new StringReader(
    "<Book id=\"b1\"><Title>T</Title><Available>true</Available><Extra>x</Extra></Book>"))!;
Console.WriteLine($"deserialized: {back.Id} {back.Title} {back.Available}");
Text
deserialized: b1 T True

The unknown Extra element was ignored and the known fields were filled. By default XmlSerializer raises no error for unexpected elements or attributes, which keeps a reader working when a sender adds a field. It also means a misspelled or renamed element is silently dropped and the property stays at its default: an Available that is spelled available in the input (case matters) stays false. If you want to know, subscribe to the UnknownElement and UnknownAttribute events on the serializer and log or throw. For strictness at the contract level, validate the document against a schema before deserializing (validating XML against an XSD schema).

A value that does not fit the type, such as true replaced by yes for a bool, is expected to raise InvalidOperationException with the line and position (I did not run that case here), so type errors are loud while name errors are quiet.

Design notes

Public parameterless constructor, public properties. XmlSerializer needs both. Properties without setters and private fields are skipped, and lists need a getter (a list that is null gets created, a read-only one is filled).

Property order is declaration order. The elements are written in the order of the properties, which matters when the receiver validates against a schema with a sequence. [XmlElement(Order = 1)] makes the order explicit.

Dates. A DateTime is written as an XML Schema dateTime with the time zone offset when its kind is not unspecified, and mixing kinds produces surprises. Use DateTimeOffset for anything that crosses systems.

Decimals. They are written in the invariant format, so serialization is not subject to the culture trap that affects decimal.Parse (reading XML in C# with XDocument).

Newer options. .NET also has DataContractSerializer, which is stricter about order and namespaces, and source-generated serializers for JSON. For XML, XmlSerializer remains the general tool.

Testing what you emit

Serialization code is easy to break by renaming a property, and the break is invisible until a partner complains. Keep a golden file: serialize a fixed object in a test and compare the output to a file checked into the repository, after normalizing the line endings. Add a second test that deserializes the golden file and compares the object. When a change alters the XML, the test fails and the diff shows exactly what a consumer would see. If a schema exists, validate the golden file against it in the same test (validating XML against XSD with xmllint). These two tests catch property renames, order changes, namespace changes and the encoding mismatch in one place.

Security

Deserializing XML from outside creates objects from data you did not write. XmlSerializer is much safer than binary formatters, because it only populates the public properties of the type you name, and it does not instantiate arbitrary types from the document. Still, apply the reader settings: pass an XmlReader created with DtdProcessing.Prohibit rather than a StringReader, so DTDs and entity expansion are off (safe XML handling in PHP and Node covers the equivalent in other languages).

For reading XML without classes, see reading XML in C# with XDocument and LINQ to XML, and for the language overview parsing XML in Java, C# and other typed languages. XmlSerializer is documented in the .NET XML serialization guide and XmlWriterSettings in the API reference. Test a document with an XPath in the XPath tester.

When XmlSerializer is not the right writer

The format is document-like. Mixed content and arbitrary nesting are awkward to model with classes. Build the document with XDocument.

The schema is fixed by a partner. Generate classes from the XSD with xsd.exe or write the elements with XmlWriter so that order, namespaces and optionality match exactly.

You need streaming output. For millions of records, write with XmlWriter one element at a time. Serializing a list builds all the objects first.

You need full control of whitespace and escaping. Use XmlWriter with explicit settings.

Also inTooling
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.