Allocation-free XML serializer/deserializer based on C# incremental source generators (ISG). Because generation happens at compile time, there is no runtime reflection cost and no performance degradation as the number of serialized types grows.
XmlSerDe's purpose is POCO ↔ XML data binding: mapping plain C# classes to XML and back, format-compatible with System.Xml.Serialization for the primitives, collections, and xsi:type polymorphism it supports. It targets that data-binding scenario specifically rather than being a general-purpose XML processor — see Out of scope by design for what that excludes and why. Existing XmlSerializer code can be moved over one project at a time without being rewritten — see Drop-in mode, and note what it deliberately does not accelerate.
The API and behavior may still change between releases.
Serialization code is generated at compile time, so there is no runtime reflection, no first-call warm-up, and no degradation as the number of serialized types grows. Deserialization reads the document as a ReadOnlySpan<char> and never materializes an intermediate node tree. Measured against System.Xml.Serialization on the same documents and the same machine, that buys four things:
- Deserialization allocates the resulting object graph and nothing else — 808 B against 16 898 B on the REGULAR document, under 5% of the baseline, and none of it survives to gen1 while every
System.Xmlrun promotes something. Entity references and CDATA are expanded straight out of the span; array members are accumulated throughArrayPool<T>rather than a throwawayList<T>. - Deserialization is single-pass. Each element's tag head is scanned exactly once, so cost is linear in document size and independent of nesting depth: 3.8× faster than
System.Xmlon a shallow document, 5.7× on one nested 100 levels deep. - Serialization offers three modes with different allocation profiles, down to 384 B — 3% of the baseline — when writing UTF-8 straight to a stream, at the same speed as the other two.
- The hot paths are multi-targeted.
SearchValues<char>, spanTryFormatand spanParseoverloads are used on net8.0+; the netstandard2.0 fallbacks are still 1.4–1.9× faster thanSystem.Xmlon the runtime that consumes them.
Two document shapes, each with its own System.Xml baseline:
- REGULAR — 26 elements, maximum nesting depth 6, indented, with derived types dispatched by
xsi:type, an enum, aDateTime, entity-encoded text and CDATA. This isComplexFixture.AuxXml, reproduced at the end of this section. - DEEP — one element inside another, 100 levels down, a single string at the bottom, no indentation (
DeepFixtureinXmlSerDe.Tests/Deep). The same work in a different shape: wide-and-shallow becomes narrow-and-deep, which is what makes any per-ancestor re-walking visible.
BenchmarkDotNet v0.15.2, Windows 11, 13th Gen Intel Core i7-13700H, .NET SDK 10.0.302. Reproduce with run-benchmarks.bat.
Run under all three target frameworks. .NET Framework 4.7.2 is not there for .NET Framework's own sake — it is the only way to execute the netstandard2.0 assemblies, so that row is the #else branches being measured.
| Method | Runtime | Categories | Mean | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio |
|----------------------------------- |--------------------- |----------- |----------:|------:|-------:|-------:|----------:|------------:|
| 'Deserialize: DEEP: System.Xml' | .NET 10.0 | DEEP | 15.922 us | 1.00 | 2.3193 | 0.1221 | 29824 B | 1.00 |
| 'Deserialize: DEEP: XmlSerDe' | .NET 10.0 | DEEP | 2.817 us | 0.18 | 0.2594 | - | 3272 B | 0.11 |
|----------------------------------- |--------------------- |----------- |----------:|------:|-------:|-------:|----------:|------------:|
| 'Deserialize: DEEP: System.Xml' | .NET 8.0 | DEEP | 16.971 us | 1.00 | 2.3193 | 0.1221 | 29880 B | 1.00 |
| 'Deserialize: DEEP: XmlSerDe' | .NET 8.0 | DEEP | 2.703 us | 0.16 | 0.2594 | - | 3272 B | 0.11 |
|----------------------------------- |--------------------- |----------- |----------:|------:|-------:|-------:|----------:|------------:|
| 'Deserialize: DEEP: System.Xml' | .NET Framework 4.7.2 | DEEP | 21.761 us | 1.00 | 5.2795 | 0.4578 | 33250 B | 1.00 |
| 'Deserialize: DEEP: XmlSerDe' | .NET Framework 4.7.2 | DEEP | 11.460 us | 0.53 | 0.5188 | - | 3290 B | 0.10 |
|----------------------------------- |--------------------- |----------- |----------:|------:|-------:|-------:|----------:|------------:|
|----------------------------------- |--------------------- |----------- |----------:|------:|-------:|-------:|----------:|------------:|
| 'Deserialize: REGULAR: System.Xml' | .NET 10.0 | REGULAR | 7.951 us | 1.00 | 1.3428 | 0.0610 | 16898 B | 1.00 |
| 'Deserialize: REGULAR: XmlSerDe' | .NET 10.0 | REGULAR | 2.071 us | 0.26 | 0.0610 | - | 808 B | 0.05 |
|----------------------------------- |--------------------- |----------- |----------:|------:|-------:|-------:|----------:|------------:|
| 'Deserialize: REGULAR: System.Xml' | .NET 8.0 | REGULAR | 8.620 us | 1.00 | 1.3428 | 0.0610 | 16888 B | 1.00 |
| 'Deserialize: REGULAR: XmlSerDe' | .NET 8.0 | REGULAR | 2.510 us | 0.29 | 0.0610 | - | 808 B | 0.05 |
|----------------------------------- |--------------------- |----------- |----------:|------:|-------:|-------:|----------:|------------:|
| 'Deserialize: REGULAR: System.Xml' | .NET Framework 4.7.2 | REGULAR | 11.306 us | 1.00 | 2.7161 | 0.1221 | 17114 B | 1.00 |
| 'Deserialize: REGULAR: XmlSerDe' | .NET Framework 4.7.2 | REGULAR | 8.218 us | 0.73 | 0.1984 | - | 1292 B | 0.08 |
Gen0 and Gen1 are collections per 1000 operations, and they are the part of the memory story Allocated cannot tell. XmlSerDe triggers no gen1 collection in any of the six pairs; System.Xml triggers one in all six, because its intermediate reader state outlives the gen0 collection that its own allocation rate provokes. Nothing XmlSerDe allocates survives long enough to be promoted — what is left is the object graph, and the caller is still holding that. The gen0 rate falls 22× on REGULAR under .NET 10 and 10× on DEEP under .NET Framework, where the baseline is heaviest.
Measured on net10.0 only: serialization touches neither XmlScan nor XmlTextDecoder, so it says nothing about the difference between the netstandard2.0 and net8.0+ branches.
| Method | Mean | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio |
|------------------------------- |---------:|------:|-------:|-------:|----------:|------------:|
| 'Serialize: System.Xml' | 3.751 us | 1.00 | 1.0681 | 0.0381 | 13456 B | 1.00 |
| 'Serialize: XmlSerDe' | 1.134 us | 0.30 | 0.5512 | 0.0057 | 6928 B | 0.51 |
| 'Serialize: XmlSerDe (est)' | 1.047 us | 0.28 | 0.4025 | 0.0019 | 5072 B | 0.38 |
| 'Serialize: XmlSerDe (stream)' | 1.140 us | 0.30 | 0.0305 | - | 384 B | 0.03 |
The three XmlSerDe rows are three exhausters, not three implementations:
- plain — appends into a
StringBuilderthat grows as needed. (est)— runs a length-estimation pass first, then serializes into a pre-sizedStringBuilder. One extra walk of the object graph buys the removal of every intermediate buffer: same time, 27% fewer bytes.(stream)— writes UTF-8 binary throughUtf8BinaryExhausterinto anArrayPool<byte>buffer (the benchmark discards the output). No string is ever built, which is where the 384 B comes from.
- Read
Ratio, notMean. Every absolute figure here is specific to one machine, one SDK and one OS build.System.Xmlis code neither project controls, and across earlier runs of this same benchmark its REGULAR baseline has moved between 7.3 and 9.2 us. Comparing microseconds across runs mostly measures the machine; comparing a benchmark to the baseline captured beside it does not. - Each runtime is its own comparison. .NET Framework's
System.Xmlis ~40% slower than .NET 10's to begin with, so its rows must be read against its own 1.00 and never against .NET 10's. Doing that, XmlSerDe wins by 1.9× on DEEP and 1.4× on REGULAR there, versus 5.7× and 3.8× on .NET 10. Alloc Ratiois the more stable of the two. Allocation is deterministic — it does not drift with CPU frequency, background load or JIT tiering — so 0.05 on REGULAR is a firmer claim than any timing on this page.- DEEP is a stress shape, not a realistic document. It exists to make an O(size × depth) algorithm impossible to miss; see below.
Deserialization allocates the object graph, and only the object graph. 808 B on REGULAR is exactly the resulting objects — the same property DEEP has, where all 3272 B are the 100 nodes plus their payload string. Two separate sources of waste were removed to get there, both found by decomposing an allocation figure rather than by reading code (GC.GetAllocatedBytesForCurrentThread deltas around a single warmed-up call, which agree with BenchmarkDotNet to the byte):
| Before | After | |
|---|---|---|
| A member with entity references | 232 B | 104 B |
| A member with two CDATA sections | 384 B | 176 B |
| REGULAR document, whole deserialize | 1144 B | 808 B |
WebUtility.HtmlDecode takes a string, so every text body containing a reference had to be materialized just to be handed over and thrown away — 336 of 1144 bytes on REGULAR, 29%, that never reached the result. XmlTextDecoder expands references and CDATA straight out of the span into a buffer (stack below 256 chars, ArrayPool above) and materializes exactly once.
Array members were accumulated in a List<T> and copied out with ToArray(), costing the list object, its backing array, another array per doubling, and finally the result. PooledArrayBuilder<T> takes the intermediate buffers from ArrayPool<T>:
| Member | Before | After | The result itself |
|---|---|---|---|
int[1] |
128 B | 56 B | 32 B + 24 B for the POCO |
int[10] |
304 B | 88 B | 64 B + 24 B |
int[100] |
1632 B | 448 B | 424 B + 24 B |
int[1000] |
12472 B | 4048 B | 4024 B + 24 B |
The "after" column is exactly the array plus the object holding it: the overhead is not reduced but gone. Neither benchmark document has an array member, so this does not appear in the tables above — it was measured directly.
DEEP is in the suite because it once read 9.39. Deserialization used to be quadratic in nesting depth: 26% faster than System.Xml at depth 6, 9.4× slower at depth 100. To hand back a node, XmlNode2.GetFirst first had to know where that node ended, and it found out by recursively parsing the node's entire subtree and discarding the result — so the subtree of a node at depth d was re-walked once per ancestor. Instrumentation counted 110 head scans per deserialize of a 26-element document, of which 78 existed only to skip over subtrees, with total character traffic 3.4× the document length.
| Category | Ratio before | Ratio now |
|---|---|---|
| DEEP | 9.39 | 0.18 |
| REGULAR | 0.74 | 0.26 |
The fix was to stop measuring nodes before parsing them: generated DeserializeBody methods now report how much input they consumed, XmlScan.ReadHead reads one tag head and never descends, and an unbound element is skipped by a cheap quote-aware tag-balance count instead of being fully parsed. XmlNode2 remains as the public node-oriented API but is off the hot path. The analysis, the counters and the design are in docs/perf-single-pass-parser.md; the earlier investigation that first identified the multiplier is in docs/perf-redundant-head-scans.md. Both also document the benchmarking methodology — including why an A/B switch must be a static readonly field read from an environment variable (a plain mutable static bool breaks inlining and distorted an entire run by ~1 us).
Two behaviours changed as a side effect, both strictly less lossy than before: a self-closing child no longer ends the sibling loop (GetFirstLength returned length 0 for a bodyless node, which the generated loop read as "no more children", silently dropping everything after <Foo/>), and a polymorphic member is now dispatched by member name first and xsi:type second.
Spec compliance is not paid for in the hot loop. Quote-aware tag-head scanning, the full S production for whitespace, and attribute-value normalization initially cost more than intended, because IndexOfAny("/> \t\r\n") is six characters and the runtime only vectorizes IndexOfAny(ReadOnlySpan<T>) for up to five, falling back to a probabilistic scan beyond that. On net8.0+ that search is now a SearchValues<char>, which builds an ASCII bitmap once per process and has no such limit. The netstandard2.0 branch keeps the original workaround: one vectorized IndexOfAny('/', '>', ' ') plus a scalar sweep of the short prefix for tab/CR/LF — those are all below ' ' and a legal name character is always above it, so the test is equivalent to a second vectorized search but cheaper than setting one up.
The netstandard2.0 gap is visible and explainable. REGULAR allocates 1292 B there against 808 B elsewhere: net8.0+ passes the ReadOnlySpan<char> straight into int.Parse/DateTime.Parse, while netstandard2.0 has no span overloads and must materialize a string per parsed value. DEEP does no parsing at all — one string member at the bottom of the chain — so it stays essentially flat, 3290 B against 3272 B.
net10.0 and net8.0 are within noise of each other, in both directions across the two categories. That is the expected result rather than a surprise: every fast path is gated on NET8_0_OR_GREATER and nothing is gated on net9 or net10, so the two builds compile the same source. The third target is there to keep that claim honest, and to be where a net9/net10-only API — say SearchValues.Create(ReadOnlySpan<string>) for entity names — would land if one were added.
run-benchmarks.batThe batch file builds in Release and prints only the result tables; dotnet build output and BenchmarkDotNet's own progress log go to benchmarks.log.
Program.Main runs two fixtures. They are split because BenchmarkDotNet assigns a job to a whole class — a single [Benchmark] cannot pick its own runtime:
| Fixture | Runtimes | What it covers |
|---|---|---|
SerializeFixture |
net10.0 | Serialize: — plain, length-estimated, and stream variants |
DeserializeFixture |
net472, net8.0, net10.0 | Deserialize: — DEEP and REGULAR categories |
The other fixtures are listed in Program.Main commented out, to be swapped in as needed. Among them, AllocationHotspotsFixture isolates the individual allocation sources that the enum and Guid fixes addressed (Enum.ToString(), Enum.Parse boxing, StringBuilder.Append(object?) boxing of Guid), measuring each on its own and in small batches so the per-call cost shows up in Allocated rather than being lost in the noise of a full document parse. It and XmlDecodeStringFixture measure APIs that do not exist on netstandard2.0 (Enum.Parse(Type, ReadOnlySpan<char>), Encoding.GetBytes(string, Span<byte>)), so they are excluded from the net472 compile rather than rewritten into measuring something else.
<InfoContainer>
<InfoCollection>
<BaseInfo xmlns:p3="http://www.w3.org/2001/XMLSchema-instance" p3:type="Derived3Info">
<Email>example@example.com</Email>
</BaseInfo>
<BaseInfo xmlns:p3="http://www.w3.org/2001/XMLSchema-instance" p3:type="Derived1Info">
<BasePersonificationInfo>my string !@#$%^&*()_+|-=\';[]{},./<>?</BasePersonificationInfo>
</BaseInfo>
<BaseInfo xmlns:p3="http://www.w3.org/2001/XMLSchema-instance" p3:type="Derived2Info">
<HotKeyUsed>false</HotKeyUsed>
<StepsCounter>1</StepsCounter>
<EventsTime>
<SerializeKeyValue>
<Key>Three</Key>
<Value>
<StartTime>2022-09-28T14:51:39.2438815+03:00</StartTime>
<SecondsSpan>3</SecondsSpan>
</Value>
</SerializeKeyValue>
<SerializeKeyValue>
<Key>One</Key>
<Value>
<StartTime>2022-09-28T14:28:00.5009069+03:00</StartTime>
<SecondsSpan>0</SecondsSpan>
</Value>
</SerializeKeyValue>
<SerializeKeyValue>
<Key>Two</Key>
<Value>
<StartTime>2022-09-28T14:28:02.3089553+03:00</StartTime>
<SecondsSpan>1</SecondsSpan>
</Value>
</SerializeKeyValue>
</EventsTime>
</BaseInfo>
</InfoCollection>
</InfoContainer>The serializer declaration it is bound to, and the four benchmarked entry points:
public InfoContainer Deserialize(ReadOnlySpan<char> xml)
{
XmlSerializerDeserializer.Deserialize(DefaultInjector.Instance, xml, out InfoContainer r);
return r;
}
public string Serialize()
{
var dsbe = new DefaultStringBuilderExhauster();
XmlSerializerDeserializer.Serialize(dsbe, DefaultObject, false);
return dsbe.ToString();
}
public string Serialize_Est()
{
var dlee = new DefaultLengthEstimatorExhauster();
XmlSerializerDeserializer.Serialize(dlee, DefaultObject, false);
var estimateXmlLength = dlee.EstimatedTotalLength;
var dsbe = new DefaultStringBuilderExhauster(new StringBuilder(estimateXmlLength));
XmlSerializerDeserializer.Serialize(dsbe, DefaultObject, false);
return dsbe.ToString();
}
public void Serialize_ToStream_Test()
{
var be = new Utf8BinaryExhausterEmpty();
XmlSerializerDeserializer.Serialize(be, DefaultObject, false);
}
[XmlExhauster(typeof(DefaultLengthEstimatorExhauster))]
[XmlExhauster(typeof(DefaultStringBuilderExhauster))]
[XmlExhauster(typeof(Utf8BinaryExhausterEmpty))]
[XmlSubject(typeof(SerializeKeyValue), false)]
[XmlSubject(typeof(PerformanceTime), false)]
[XmlSubject(typeof(InfoContainer), true)]
[XmlSubject(typeof(BaseInfo), false)]
[XmlFactory(typeof(InfoContainer), "global::" + "XmlSerDe.Tests.Complex.Subject" + "." + nameof(CachedInfoContainer) + "." + nameof(CachedInfoContainer.Reuse) + "()")]
public partial class XmlSerializerDeserializer
{
}| Project | Target | Role |
|---|---|---|
| XmlSerDe.Common | netstandard2.0; net8.0; net10.0 | Attributes, IInjector / IExhauster contracts, XmlScan/XmlTextDecoder, and XmlNode2 — a low-allocation XML node parser over ReadOnlySpan<char>. |
| XmlSerDe.Components | netstandard2.0; net8.0; net10.0 | Default runtime implementations: injectors and exhausters. |
| XmlSerDe.Generator | netstandard2.0 (Roslyn analyzer) | Incremental source generator that emits serialization/deserialization code at compile time. |
| XmlSerDe.Compat | netstandard2.0; net8.0; net10.0 | Optional. A facade that is a System.Xml.Serialization.XmlSerializer — see Drop-in mode. Referenced only if you want it; nothing else depends on it. |
| XmlSerDe.Tests | net472; net8.0; net10.0 | Functional tests (xUnit). |
| XmlSerDe.PerformanceTests | net472; net8.0; net10.0 | BenchmarkDotNet benchmarks vs System.Xml.Serialization. |
The two runtime libraries are multi-targeted so that a consumer on a modern runtime gets the fast paths — SearchValues<char>, span TryFormat, span Parse overloads — while a netstandard2.0 consumer still compiles and runs. Only method bodies differ between targets; the public surface is identical, because the generator itself is netstandard2.0 and compiles against that build. net472 in the test and benchmark projects is not about .NET Framework: netstandard2.0 cannot be executed directly, and net472 is what consumes those assets, so it is the only target under which the #else branches actually run. The cost of those fallbacks is measured, not assumed — see What the numbers mean.
Dependency flow: consumer app → XmlSerDe.Common + XmlSerDe.Components + XmlSerDe.Generator (analyzer).
<ProjectReference Include="..\XmlSerDe.Common\XmlSerDe.Common.csproj" />
<ProjectReference Include="..\XmlSerDe.Components\XmlSerDe.Components.csproj" />
<ProjectReference Include="..\XmlSerDe.Generator\XmlSerDe.Generator.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />public class Order
{
public int Id { get; set; }
public string CustomerName { get; set; }
public List<OrderLine> Lines { get; set; }
}
public class OrderLine
{
public string Product { get; set; }
public int Quantity { get; set; }
}using XmlSerDe.Common;
using XmlSerDe.Components.Exhauster;
using XmlSerDe.Components.Injector;
[XmlSubject(typeof(OrderLine), false)]
[XmlSubject(typeof(Order), true)] // true = root type (public entry point)
public partial class OrderSerializer
{
}The class must be partial. On build, the generator emits OrderSerializer.g.cs with Serialize and Deserialize methods.
var exhauster = new DefaultStringBuilderExhauster();
OrderSerializer.Serialize(exhauster, order, appendXmlHead: false);
string xml = exhauster.ToString();OrderSerializer.Deserialize(
DefaultInjector.Instance,
xml.AsSpan(),
out Order result);If the input includes an XML declaration (<?xml ...?>), strip it first:
var body = BuiltinCodeHelper.CutXmlHead(fullXml.AsSpan());
OrderSerializer.Deserialize(DefaultInjector.Instance, body, out Order result);The route above is the native one: you declare the serializer class, and every call site names it. XmlSerDe.Compat is the other route — for code that already calls System.Xml.Serialization.XmlSerializer and would rather not be rewritten. It is not a full drop-in replacement; see What it does not accelerate and Limitations before reaching for it. In particular, it inherits the untrusted-input caveat: the facade does not add well-formedness validation, so an accelerated type is no safer on hostile input than the native route.
Reference the extra project and add one line to your own:
<ProjectReference Include="..\XmlSerDe.Compat\XmlSerDe.Compat.csproj" />global using XmlSerializer = XmlSerDe.Compat.XmlSerializer;That is the whole setup. There is no serializer class to declare and no registration to write: the generator finds every new XmlSerializer(typeof(T)) whose argument is a literal typeof, walks the object graph from T transitively — members, collection element types, [XmlInclude] derived types, none of which the call site names — and registers the generated delegates from a [ModuleInitializer], before your first line of code runs.
XmlSerDe.Compat.XmlSerializer derives from System.Xml.Serialization.XmlSerializer, so an instance can be handed to DI, to a field, or to third-party code that has never heard of XmlSerDe:
var serializer = new XmlSerializer(typeof(Order)); // resolves to the facade
// fast path: the static type at the call site is the facade
string xml = serializer.SerializeToString(order);
var back = (Order)serializer.Deserialize(xml.AsSpan());
// also works, at a cost — see below
System.Xml.Serialization.XmlSerializer asBcl = serializer;
asBcl.Serialize(Console.Out, order);Two things get cheaper, and they are cheaper for different reasons.
Per call. System.Xml.Serialization walks a reflection-built plan and materializes an intermediate node tree; the generated code does neither. A round trip of a three-member document (int, string, List<string> of two items), 1000 iterations after warm-up:
| Round trip | Allocated per call |
|---|---|
facade, SerializeToString + Deserialize(span) |
1.0 KB |
facade, Serialize(TextWriter) + Deserialize(TextReader) |
2.0 KB |
System.Xml.Serialization, same TextWriter/TextReader |
29.4 KB |
Per process. System.Xml.Serialization builds a serialization plan for each type on first use — measured cold on the same machine: ~40 ms for the first XmlSerializer in the process (that one warms the shared infrastructure), then ~1.5–2 ms of constructor plus ~0.5 ms of first call for every further type. Constructing the facade for an accelerated type instead does a dictionary lookup, and the BCL serializer for that type is never built at all — the fallback field stays null unless something actually needs it.
(Both figures are quick in-process measurements, not BenchmarkDotNet runs; the rigorous numbers for the underlying engine are in Performance.)
So it pays off when:
- you serialize a lot. Message pumps, per-request payloads, batch jobs — anything where the per-call cost is multiplied by a large number;
- process startup is on the clock. CLI tools, serverless functions, desktop app launch: a project with a dozen serialized types pays tens of milliseconds to
System.Xml.Serializationbefore doing any work, and accelerated types skip it; - allocation rate is the problem, not raw speed — a service whose gen0 collections are driven by XML traffic;
- documents are deeply nested. Deserialization is single-pass, so cost does not grow with depth (5.7× on a 100-level document).
It buys you nothing — or close to nothing — when:
- the types are refused. See What it does not accelerate; the code still works, at exactly the speed it had before;
- the calls go through
XmlWriter/XmlReader/XmlSerializerNamespaces. Those paths materialize the document or hand the call over entirely — see What the fast path costs elsewhere. If that is all your code does, the facade adds a layer and returns nothing; - serialization happens once, at startup, on a small config file. Saving 2 ms on a 300 ms boot is not a reason to add a dependency;
- the bytes must match
System.Xml.Serializationexactly — see Where the output differs; - the input is untrusted. XmlSerDe does not validate well-formedness; that is a property of the engine, and the facade does not change it.
A useful way to decide: the facade helps in proportion to how much of your XML work already goes through the fast overloads on types the generator accepts. Measure that share first — Verifying the migration shows how to get the list.
A type that the generator will not serve is not an error: it falls back to System.Xml.Serialization.XmlSerializer and keeps working, just without the speed-up. The generator refuses a type whole rather than emitting almost-correct code for it, so a refusal is triggered by any of:
- any type in the graph is not a class (a
structis refused, though the BCL supports it), or is generic, or — unlessabstract— has no accessible parameterless constructor; - an
abstracttype declares no[XmlInclude], or is the root itself: dispatch byxsi:typeat the top of the document is not supported; - a member type is a collection other than
List<T>orT[](aHashSet<T>is refused, though the BCL supports it), or a nested collection (List<List<int>>), or any other generic.
Some call sites aren't accelerated either: new XmlSerializer(someTypeVariable) cannot be resolved at build time and is not seen at all. Some paths aren't either — see the table below. XmlSerDe.Compat.XmlSerializer.IsAccelerated answers the question for an instance you already hold.
Every refusal is reported, because "why didn't mine get faster" should have an answer:
| Diagnostic | Meaning | Default severity |
|---|---|---|
XMLSERDE001 |
this type falls back, with the reason and the call-site location | Info |
XMLSERDE002 |
code generation was abandoned after the graph walk had accepted the type — a gap in the generator, not a refusal by design | Warning |
Refusing one type never affects the others. If your project depends on the acceleration, make the fallback loud:
<PropertyGroup><XmlSerDeCompatStrict>error</XmlSerDeCompatStrict></PropertyGroup>
<ItemGroup><CompilerVisibleProperty Include="XmlSerDeCompatStrict" /></ItemGroup>warning and true raise XMLSERDE001 to a warning; error fails the build. The strict setting changes only the volume, never the decision — the same types are accelerated either way. CompilerVisibleProperty is required: without it the generator cannot see the property.
The allocation figures in Performance are for the span API, and the facade does not extend them to every overload:
| Call | Path |
|---|---|
SerializeToString, Deserialize(ReadOnlySpan<char>) |
generated code only — nothing else is materialized |
Serialize(TextWriter), Serialize(Stream) |
the document is built as a string first, then written (and encoded, for a stream) |
Deserialize(TextReader), Deserialize(Stream) |
the input is read to a string first (ReadToEnd), then parsed from its span |
Serialize(XmlWriter), Deserialize(XmlReader) — i.e. any call through the base type |
same materialization, plus the XmlWriter/XmlReader themselves: the document goes out through WriteRaw and comes in through ReadOuterXml |
Serialize(…, XmlSerializerNamespaces), CanDeserialize |
handed to System.Xml.Serialization in full |
The XmlSerializerNamespaces overloads are given away rather than approximated: XmlSerDe writes namespace declarations as fixed literals and cannot honor a caller-supplied set, so producing a document without the requested declarations would be worse than being slow.
If you have no reference to XmlSerDe.Compat, none of this exists: the generator emits nothing for compatibility, which is asserted by a test rather than promised.
Both sides read each other's documents — that is what the differential harness checks on every build, in both directions. But the two writers do not produce the same text, and if anything downstream compares XML byte-for-byte, this is the part that will surprise you. The same object, written by each side:
<!-- System.Xml.Serialization, Serialize(TextWriter) -->
<?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</Id>
<CustomerName>ACME</CustomerName>
</Order>
<!-- XmlSerDe.Compat, same call -->
<?xml version="1.0" encoding="utf-8"?><Order><Id>7</Id><CustomerName>ACME</CustomerName></Order>Three differences, all deliberate:
- No indentation. XmlSerDe writes the document compactly;
System.Xml.Serializationindents with two spaces and CRLF. Insignificant whitespace, but not identical bytes. - No
xmlns:xsi/xmlns:xsdon the root. The BCL declares both prefixes on every document whether or not they are used; XmlSerDe declaresxmlns:xsiexactly where it writesxsi:typeorxsi:nil, and nowhere else. Both are legal, and each side reads the other's form. - The declared encoding is always
utf-8. The BCL derives it from the sink, so writing to aStringWriteryieldsencoding="utf-16"— technically the truth about a UTF-16 string in memory, and a lie about the same text once written to a file. PassappendXmlDeclaration: falsetoSerializeToStringif you would rather write the prolog yourself.
Beyond the text, two behavioral differences worth knowing before you migrate:
- The
UnknownElement/UnknownAttribute/UnknownNode/UnreferencedObjectevents do not fire on the fast path. XmlSerDe skips unrecognized elements silently — as does the BCL, but the BCL raises the event first. Code that subscribes to those events for logging or strictness gets silence instead. This is verified, not assumed: with an unknown element in the input, the BCL raised one event and the facade raised none. - Exceptions have different types. Handing an object of the wrong type to
SerializethrowsInvalidCastExceptionfrom the facade andInvalidOperationException(wrapping the real cause) from the BCL. Acatch (InvalidOperationException)that used to cover this will not catch it any more.
Null, on the other hand, behaves identically on purpose: null is written as <T xsi:nil="true" /> and read back as null, because the fast path hands nulls to System.Xml.Serialization rather than inventing an answer.
Work one project at a time — the global using is per compilation, so the blast radius of each step is one assembly.
1. Pick a project and add the reference.
<ProjectReference Include="..\XmlSerDe.Compat\XmlSerDe.Compat.csproj" />
<ProjectReference Include="..\XmlSerDe.Common\XmlSerDe.Common.csproj" />
<ProjectReference Include="..\XmlSerDe.Components\XmlSerDe.Components.csproj" />
<ProjectReference Include="..\XmlSerDe.Generator\XmlSerDe.Generator.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />2. Take the inventory before switching anything. Add the alias in a scratch commit, then build once with the fallback made loud — the default severity of XMLSERDE001 is Info, which dotnet build does not print at any verbosity:
<ItemGroup><CompilerVisibleProperty Include="XmlSerDeCompatStrict" /></ItemGroup>dotnet build -p:XmlSerDeCompatStrict=warningEvery refused type shows up as a warning pointing at the call site, with the reason:
Program.cs(13,19): warning XMLSERDE001: 'global::Bad' falls back to
System.Xml.Serialization.XmlSerializer: Set: HashSet<int> is not a supported generic type
That list is the decision: if the types you actually care about are all on it, stop here and drop the branch — this project is not a candidate.
3. Switch the alias on, in one file:
global using XmlSerializer = XmlSerDe.Compat.XmlSerializer;Anything that names System.Xml.Serialization.XmlSerializer by its full name is unaffected — the alias only rebinds the short name, so a partially migrated file keeps compiling.
4. Find the call sites the generator cannot see. new XmlSerializer(t) with a variable, a Type from configuration, or a factory that takes Type produces no acceleration and no diagnostic — there is nothing to report. Where the type is statically known, spell it out:
// invisible to the generator
static XmlSerializer For(Type t) => new XmlSerializer(t);
// visible
static XmlSerializer ForOrder() => new XmlSerializer(typeof(Order));5. Deal with the constructors that don't exist. The facade has one constructor, XmlSerializer(Type). XmlSerializer(Type, XmlRootAttribute), XmlSerializer(Type, Type[]), XmlSerializer(Type, XmlAttributeOverrides) and friends are not there, so those call sites won't compile — which is the honest outcome, since the generator could not have honored them anyway. Keep them on System.Xml.Serialization.XmlSerializer by its full name.
6. Move the hot calls onto the fast overloads. Existing code keeps working untouched, but SerializeToString(obj) and Deserialize(span) are where the numbers above come from. Passing an XmlWriter or XmlReader works and is correct — it just gives up most of the win.
7. Turn on strict mode once the list is empty, so a future edit that quietly costs you the acceleration — a new Dictionary<,> member, a type that loses its parameterless constructor — fails the build instead of getting slower:
<PropertyGroup><XmlSerDeCompatStrict>error</XmlSerDeCompatStrict></PropertyGroup>The migration is a no-op for correctness if all four of these hold. Check them in order — each catches a different failure.
1. The build is clean under strict mode.
dotnet build -p:XmlSerDeCompatStrict=errorNo XMLSERDE001 means every call site the generator saw was accepted — it says nothing about the ones it never saw, which is what check 2 is for. XMLSERDE002 means the graph walk accepted a type the code generator then choked on: that is a bug in XmlSerDe, not in your code — the affected types fall back and keep working, and the message is worth reporting.
2. The types you expect are actually registered, at runtime, where a refactor can't silently undo it:
var serializer = new XmlSerializer(typeof(Order));
Debug.Assert(serializer.IsAccelerated, "Order is no longer served by the generated code");IsAccelerated is the only way to tell the two paths apart from the outside — a fallback is invisible otherwise, which is exactly why the property exists.
3. Old documents still read, and new documents still parse elsewhere. Round-tripping through XmlSerDe alone proves nothing: two consistent-but-wrong halves cancel out. Compare against the BCL in both directions, which is what the project's own harness does:
var ours = new XmlSerializer(typeof(Order));
var bcl = new System.Xml.Serialization.XmlSerializer(typeof(Order));
// they read our documents
var theirs = (Order)bcl.Deserialize(new StringReader(ours.SerializeToString(order)));
// we read theirs
var writer = new StringWriter();
bcl.Serialize(writer, order);
var mine = (Order)ours.Deserialize(writer.ToString().AsSpan());Keep a corpus of real documents from production for this — the shapes that break interop are the ones nobody thought to write a POCO for.
4. Nothing downstream depends on the exact bytes. Grep for golden-file comparisons, XML stored as a string and compared for equality, checksums over serialized payloads, and schema validation that expects the xsd/xsi declarations. See Where the output differs — this is the failure that shows up in someone else's test suite, not yours.
You register types and configure code generation by decorating a single partial class with attributes from XmlSerDe.Common. All attributes can be applied multiple times to the same class.
Registers a type for serialization and deserialization.
| Parameter | Meaning |
|---|---|
SubjectType |
The CLR type to handle. Every member type used in the object graph must be registered before it appears in another type. |
IsRoot |
true — generates a public Deserialize(injector, xml, out T) and root Serialize(exh, obj, appendXmlHead) for this type. Typically exactly one root per serializer class. |
Enables polymorphic serialization and deserialization via xsi:type. Goes on the base type, not on the serializer class, and is the very same attribute System.Xml.Serialization reads — so a type already annotated for the BCL needs nothing added.
The generator registers each included type as a subject on its own, so a derived type needs no [XmlSubject] of its own. Includes are followed recursively: a derived type may declare its own. Abstract bases require at least one include.
Registers an IExhauster implementation. The generator emits a Serialize overload for each registered exhauster. If omitted, DefaultStringBuilderExhauster is used automatically.
Registers an IInjector implementation. The generator emits a Deserialize overload for each registered injector. If omitted, DefaultInjector is used automatically.
Replaces new T() during deserialization with a custom C# expression. Useful for object pooling and reuse of already-allocated instances.
[XmlFactory(typeof(InfoContainer), "global::MyApp.CachedInfoContainer.Reuse()")]The factory type must provide a Reset()-style method that clears state before reuse. See CachedInfoContainer in XmlSerDe.Tests/Complex/Subject/InfoContainer.cs.
[XmlInclude(typeof(Derived1Info))]
public abstract class BaseInfo { /* ... */ }
[XmlSubject(typeof(BaseInfo), false)]
[XmlSubject(typeof(InfoContainer), true)]
public partial class MySerializer { }Produces XML compatible with System.Xml.Serialization polymorphism:
<BaseInfo xmlns:p3="http://www.w3.org/2001/XMLSchema-instance" p3:type="Derived1Info">
<BasePersonificationInfo>my string</BasePersonificationInfo>
</BaseInfo>An injector (IInjector) parses primitive/builtin values from XML nodes. It is the deserialization counterpart to an exhauster.
For each supported builtin type, IInjector defines:
Parse(ref XmlDeserializeSettings, fullNode, xmlnsAttributeName, out T)— expects the XSD wrapper element (<dateTime>,<int>, etc.) inside the property element.ParseBody(body, out T)— parses only the inner text.
DefaultInjector (singleton: DefaultInjector.Instance):
- Validates the declared XSD element name, then delegates to
ParseBody. - Uses standard
*.Parsemethods for numeric types,DateTime.Parse,Guid.Parse, etc. - Culture-invariant: every numeric and
DateTimeparse path usesCultureInfo.InvariantCultureexplicitly, matching XSD's fixed lexical space regardless of the ambient thread culture. - Strings: supports CDATA blocks (concatenates multiple), then
WebUtility.HtmlDecode. - Booleans:
"true"/"false".
Implement IInjector and register it with [XmlInjector(typeof(MyInjector))]. Override ParseBody to change format — for example, a fixed DateTime format:
public class IsoDateInjector : DefaultInjector
{
public new void ParseBody(ReadOnlySpan<char> body, out DateTime result)
{
result = DateTime.ParseExact(body, "yyyy-MM-dd", CultureInfo.InvariantCulture);
}
}The generator emits a Deserialize(MyInjector inj, ReadOnlySpan<char> xml, out TRoot) overload that routes all builtin parsing through your injector.
XmlNode2 in XmlSerDe.Common is a ref struct that walks XML without allocating DOM nodes. The root Deserialize overload builds XmlDeserializeSettings from heuristics (whether comments or CDATA blocks are likely present) and iterates child nodes via XmlNode2.GetFirst.
Its attribute parser follows XML 1.0's actual grammar rather than a narrow subset:
- Quoting:
AttValuemay be delimited by either"or'(the closing quote must match the opening one). - Namespace prefix optional: unprefixed attributes (e.g.
id="1") are recognized alongside prefixed ones (e.g.p3:type="..."). - Whitespace: the XML
Sproduction (#x20 | #x9 | #xD | #xA) is honored between the element name and its attributes, not just a literal space. >inside attribute values: per XML 1.0 §2.4,AttValueonly requires escaping<,&, and the matching quote character —>is legal unescaped (e.g.<Foo attr="1>2">). The tag-head scan is quote-aware, so it doesn't mistake such a>for the tag's actual close.- Attribute-value normalization (XML 1.0 §3.3.3): entity/character references (
&,1) are decoded via the sameWebUtility.HtmlDecodeconvention used for element text, and literal tab/CR/LF characters are collapsed to a single space — but a character reference that expands to whitespace (e.g. ) is inserted verbatim and is not collapsed, matching the spec's normalization algorithm. Allocates only when a reference or literal tab/CR/LF is actually present.
BuiltinCodeHelper.CutXmlHead strips the full XML prolog (XML 1.0 §2.8: XMLDecl? Misc* (doctypedecl Misc*)?), not just the <?xml ...?> declaration — leading comments, other processing instructions (e.g. <?xml-stylesheet ...?>), and a <!DOCTYPE ...> declaration (including one with an internal subset containing its own > characters) are all skipped via XmlNode2.SkipPrologMisc, in any order/combination.
An exhauster (IExhauster) is the output sink for serialized data. For each supported builtin type it provides Append(T) and Append(T?), plus:
Append(string? value)— raw append.AppendEncoded(string? value)— validates then HTML-encodes then appends (used forstringbuiltins in element text).AppendAttributeEncoded(string? value)— the same for an attribute value, which needs a wider escape set: see the well-formedness note below.AppendBase64(byte[]? value)— abyte[]as onebase64Binarytoken. No escaping at all: the base64 alphabet contains no markup and no whitespace, so the same token serves both element text and attribute values. The length estimator is the only exhauster that does not call the encoder here — it computes the encoded length arithmetically rather than building a string only to measure it.
Null nullable values (including nullable value types like int?, DateTime?) are skipped entirely on serialize rather than emitting an empty tag.
Culture-invariant formatting: numeric and DateTime values are formatted via ISpanFormattable.TryFormat into a stack buffer with CultureInfo.InvariantCulture (falling back to an invariant-culture ToString for larger values), so output matches XSD's fixed lexical space regardless of the ambient thread culture. Guid uses the same stack-buffer TryFormat path — note that StringBuilder has no Append(Guid) overload, so appending one directly would silently bind to Append(object?) and box.
Attribute values escape more than text: in element text a tab, CR or LF is an ordinary character, but inside an attribute value a reader is required to replace each one with a space (XML 1.0 §3.3.3, attribute-value normalization) — the only way to get one through is a character reference written by the producer. So AppendAttributeEncoded (XmlAttributeEncoder) escapes < > & " plus CR, LF and TAB, and leaves the apostrophe alone since the value is always double-quoted — character for character what XmlWriter does. It returns the original string instance when there is nothing to escape, which is the overwhelmingly common case.
Well-formedness guard: AppendEncoded calls XmlCharGuard.EnsureValidXmlChars before encoding. WebUtility.HtmlEncode escapes <, >, &, ", ' but doesn't know about XML's Char production (XML 1.0 §2.2), which forbids most C0 control characters, unpaired surrogates, and U+FFFE/U+FFFF outright — there is no legal escape for these in XML. String content containing them throws ArgumentException instead of silently producing not-well-formed output. Legal whitespace (tab/CR/LF) is allowed through.
| Exhauster | Purpose |
|---|---|
DefaultStringBuilderExhauster |
Writes to a StringBuilder. Optional pre-sized constructor. Default DateTime format: yyyy-MM-ddTHH:mm:ss.fffffffK. Not thread-safe. |
DefaultLengthEstimatorExhauster |
Counts estimated character length without building output. Exposes EstimatedTotalLength — use to pre-size a StringBuilder and reduce reallocations. |
Utf8BinaryExhauster (abstract) |
Converts values to UTF-8 bytes. Small values use an internal buffer; larger values rent from ArrayPool<byte>. Subclass and implement Write(byte[] data, int length) to send data to a stream or network. |
// Estimation phase
var estimator = new DefaultLengthEstimatorExhauster();
OrderSerializer.Serialize(estimator, order, false);
var estimatedLength = estimator.EstimatedTotalLength;
// Serialization phase with pre-sized buffer
var exhauster = new DefaultStringBuilderExhauster(new StringBuilder(estimatedLength));
OrderSerializer.Serialize(exhauster, order, false);
string xml = exhauster.ToString();Estimation may be slightly slower than a single pass, but allocates less because the StringBuilder does not need to grow.
public class StreamExhauster : Utf8BinaryExhauster
{
private readonly Stream _stream;
public StreamExhauster(Stream stream) => _stream = stream;
protected override void Write(byte[] data, int length)
=> _stream.Write(data, 0, length);
}Implement IExhauster and register with [XmlExhauster(typeof(MyExhauster))]. Override Append methods to customize output format.
Set appendXmlHead: true on Serialize to prepend <?xml version="1.0" encoding="utf-8"?>.
XML element names follow XSD conventions:
| C# type | XML inner element |
|---|---|
DateTime / DateTime? |
dateTime |
Guid / Guid? |
guid |
bool / bool? |
boolean |
sbyte / sbyte? |
byte |
byte / byte? |
unsignedByte |
short / short? |
short |
ushort / ushort? |
unsignedShort |
int / int? |
int |
uint / uint? |
unsignedInt |
long / long? |
long |
ulong / ulong? |
unsignedLong |
decimal / decimal? |
decimal |
float / float? |
float |
double / double? |
double |
char / char? |
char |
TimeSpan / TimeSpan? |
duration |
string |
string (HTML-encoded on serialize) |
Three of these have a lexical form that does not follow from the type, and each matches what System.Xml.Serialization writes:
float/doubleuse the shortest round-trippable representation, but infinities are writtenINF/-INF, notInfinity.charis written as its code point, not as the character:'A'becomes65. XSD has no type for a single character, and the BCL uses one of its own fromhttp://microsoft.com/wsdl/types/.TimeSpanis an ISO-8601 duration (P1DT2H3M4.005S,PT0Sfor zero, leading minus when negative). Note thatXmlSerializeronly gainedTimeSpansupport in .NET Core — on .NET Framework it writes an empty element and loses the value, so XmlSerDe's output is not byte-compatible with it there. XmlSerDe writes the duration on every target.
byte[] is not in the table because it is not decided by type alone. It is written as a single base64Binary token — <Bytes>AQL6</Bytes>, not one element per byte — but only when the member carries no explicit [XmlArray] / [XmlArrayItem] wrapper; with one, the array goes back to being an ordinary collection. List<byte> is never base64: it stays a collection of <unsignedByte> elements. Both rules are System.Xml.Serialization's, measured rather than assumed. An empty array is an empty element, a null array is no element at all, and both an empty and a self-closed element read back as byte[0], never null.
- Classes registered with
[XmlSubject] - Enums — serialized as
<EnumTypeName>value</EnumTypeName>. The generator knows every declared member at compile time, so it emits aswitchover them on serialize and a chain of spanSequenceEqualcomparisons on deserialize, rather thanEnum.ToString()/Enum.Parse— both of which go through reflection, andEnum.Parseadditionally boxes itsobjectreturn on every call.Enum.ToString()/Enum.Parseremain as the fallback arm for values that match no declared member (undefined numeric values,[Flags]combinations), so behavior is unchanged for those. - Inheritance — via
[XmlInclude]andxsi:type - Collections —
List<T>andT[]only.byte[]is the one array that is not written element-per-item — see the note under Builtin primitives. A collection ofbyte[](byte[][],List<byte[]>), whichSystem.Xml.Serializationwrites as one<base64Binary>element per inner array, is not supported.
- Public fields and properties (including inherited) with accessible setters. A
List<T>property without a setter is the one exception: it is filled throughAddon the instance the constructor created (and skipped entirely if it created none) — matchingSystem.Xml.Serialization, which likewise skips a setter-less array, string, or complex type [XmlIgnore]members (fields as well as properties) are skipped[DefaultValue(x)]members are omitted when equal tox. Write side only —System.Xml.Serializationdoes not restore the default on read either, and doing so here would diverge from it- The
XxxSpecifiedcompanion pattern is honored: a publicboolnamed after the member plusSpecifiedgates whether the member is written, and is set totrueon read as soon as the element is seen - Private and protected members are skipped
- XML names default to the C# type and member names, and are overridden by the
System.Xml.Serializationnaming attributes:[XmlRoot],[XmlType],[XmlElement],[XmlArray],[XmlArrayItem],[XmlEnum].[XmlElement(Order = n)]/[XmlArray(Order = n)]set the element order on write. [XmlAttribute]moves a member into the owner's start tag (<Owner id="7">);[XmlText]makes it the owner's body, with no tag of its own. Both accept builtin primitives, enums andbyte[]only — everything else has no plain lexical form, andSystem.Xml.Serializationrefuses the same cases (includingNullable<T>) for the same reason. Anullstring attribute is not written at all, and an empty body leaves an[XmlText]membernull— both matching the BCL. Attribute values get their own escaping: on top of the markup characters, a literal CR, LF or TAB is written as a character reference (
	), because a reader is required to replace each of them with a space otherwise (XML 1.0 §3.3.3) — so a string with a newline in an attribute round-trips.
public class Message
{
[XmlAttribute("id")]
public int Id { get; set; } // <Message id="7">
[XmlText]
public string Body { get; set; } // <Message id="7">body</Message>
}- No CDATA serialization — string content is always emitted entity-escaped, never wrapped in
<![CDATA[...]]>. Deserialization does read CDATA sections, in any position within an element's text and any number of them. - No malformed-XML input protection — the deserializer does not validate well-formedness of its input; do not use with untrusted input. (Serialization output, by contrast, is guarded:
AppendEncodedrejects string content containing characters illegal per XML 1.0'sCharproduction — seeXmlCharGuard.) - Parameterless constructor required unless
[XmlFactory]is used. - Serialized types must be visible to the serializer partial class.
- Members need accessible setters for deserialization, except setter-less
List<T>properties (see Members). - Only
List<T>andT[]as collections. - The serializer class must be
partial. - Unknown member types cause a compile-time generator error.
- Multi-argument generics (e.g.
Dictionary<K,V>) are not supported.
See also Out of scope by design for XML 1.0 features that aren't limitations to be lifted later, but deliberate consequences of targeting the POCO ↔ XML data-binding scenario.
XmlSerDe targets POCO ↔ XML data binding, not general-purpose XML processing. The following XML 1.0 / XML Namespaces features are consequences of that scope, not oversights — each is unlikely to matter for typical data-transfer XML (including everything System.Xml.Serialization itself produces for the primitives, collections, and polymorphism XmlSerDe supports), but matters for interop with documents from other kinds of XML producers.
- No DTD support. A
<!DOCTYPE ...>in the prolog is skipped over, not parsed — so any custom general entities it declares are not resolved. Only the five predefined XML entities (&,<,>,',") plus character references (1,1) are understood; a reference to anything else — including an HTML named entity such as — throws, because without a DTD declaring it that is a well-formedness error (XML 1.0 §4.1, WFC: Entity Declared) rather than text to pass through. There's also no DTD-based content validation and no fetching of external DTDs. - No general XML Namespaces support. Only one namespace is special-cased:
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"forxsi:typepolymorphism (the prefix itself is flexible — deserialize resolves whichever prefix is actually bound to that URI). Beyond that, element and attribute names are compared as literal text, prefix included; there's no general prefix-to-URI resolution or default-namespace (xmlns="uri") handling. xml:space,xml:lang,xml:baseare not interpreted. In practice this rarely matters forxml:space: text content is always preserved verbatim regardless (matching the XML default,xml:space="preserve") — butxml:space="default", which would opt back into whitespace collapsing, has no effect either.- No mixed content. An element is parsed as either plain text or a list of child elements, never an interleaving of both — text appearing between child elements is discarded rather than bound to any member. Consequently a type that combines an
[XmlText]member with element members — whichSystem.Xml.Serializationdoes support — fails to build, rather than silently producing a document with the text missing. - No duplicate-attribute detection. XML 1.0 forbids two attributes with the same name on one element; XmlSerDe doesn't check for this and silently takes the first match.
encoding/standalonein the XML declaration are ignored on both serialize and deserialize. XmlSerDe operates on an already-decodedReadOnlySpan<char>, not raw bytes, so byte-level decoding happens before the library sees the input — a mismatch between a document's declaredencodingand how the caller actually decoded it is not detected.
XmlDeserializeGenerator (IIncrementalGenerator) triggers on any partial class decorated with [XmlSubject].
For each serializer class it emits:
| Generated file | Contents |
|---|---|
{ClassName}.g.cs |
Serialize / Deserialize for each registered type, per exhauster/injector |
BuiltinCodeHelper.MainPart.g.cs |
CutXmlHead, shared utilities |
BuiltinCodeHelper.Serialization.Shared.g.cs |
XSD type name constants |
BuiltinCodeHelper.{ExhausterName}.g.cs |
Per-exhauster builtin serialization |
BuiltinCodeHelper.{InjectorName}.g.cs |
Per-injector builtin deserialization dispatch |
Serialize: wraps objects in <TypeName> elements; polymorphic types get xmlns:xsi + xsi:type; builtin members use property wrapper + XSD inner element.
Deserialize: walks child nodes, dispatches on element name (SequenceEqual on spans), resolves polymorphism via xsi:type, constructs objects with new T() or the XmlFactory expression.
Incrementality. The usual advice for incremental generators — never put a Compilation in the pipeline, drive generation from the annotated declaration alone — does not apply here unchanged. [XmlSubject(typeof(T))] sits on the host class, but the emitted code is derived from the transitive type graph rooted at T, and those types live in other files. Since Roslyn re-runs a syntax provider's transform only for trees that changed, a generator keyed on the host's syntax node would be perfectly incremental and would happily serve stale code after a member is renamed elsewhere.
So the Compilation stays an input, and symbol binding runs on every edit. Caching comes from output equality instead: the pipeline's last step yields plain strings and diagnostic descriptions with no symbols, syntax nodes, or compilations in them, so whenever an edit doesn't change the generated text, the source-output step is reported as Cached and Roslyn reuses the already-parsed generated trees rather than re-parsing and re-binding them. That reuse, not the generator's own work, is the dominant cost in the IDE. Both halves of the contract — cache hits on irrelevant edits, cache misses on cross-file changes that matter — are covered by GeneratorIncrementalityFixture.
dotnet build XmlSerDe.sln
dotnet test XmlSerDe.Testsdotnet test runs the whole suite three times, once per target framework, so the netstandard2.0 code paths are executed rather than merely compiled.
Generated source files are written to obj/Generated/ when EmitCompilerGeneratedFiles is enabled (as in the test project).
XmlSerDe.Tests/SerDeFixture.cs exercises the main features:
| Tests | Feature |
|---|---|
XmlObject1_* |
Empty root, self-closing tags, xsi:type, XML comments, stream serialization |
XmlObject2_* |
Primitives, XML entity decoding, XML declaration stripping |
XmlObject4_5_* |
Abstract polymorphism |
XmlObject6_* |
List<string> |
XmlObject7_8_* |
Public fields |
XmlObject9_10_*, XmlObject11_12_* |
Non-abstract / abstract base polymorphism |
XmlObject13_* |
[XmlIgnore] |
XmlObject14_* |
Nullable DateTime?, Guid? |
XmlObject15_* |
Enums |
XmlObject16_17_* |
List<CustomType> |
XmlObject18_* – XmlObject22_* |
Enum and primitive arrays |
XmlObject23_24_* |
Arrays of custom types |
XmlObject25_26_27_*, XmlObject28_29_30_* |
Polymorphism on nested properties and in lists |
ComplexFixture / ComplexFixtureV2 |
Full document with derived types, enums, DateTime, XmlFactory reuse |
DeepFixture |
Self-referencing type nested 100 levels deep; guards the DEEP benchmark by asserting the whole chain is walked and matches System.Xml |
SinglePassParserFixture |
Consequences of single-pass deserialization: unknown elements skipped by tag balance (incl. > inside attribute values, CDATA, nested children), self-closing children not ending the sibling loop, mismatched/truncated closing tags rejected, compact vs. indented parity |
XmlTextDecoderFixture |
Reference expansion per XML 1.0 §4.1: the five predefined entities, decimal/hex character references incl. above-BMP surrogate pairs, CDATA in any position, attribute-value normalization vs. references, and every reference form XML rejects (undeclared, unterminated, empty, uppercase X, illegal or out-of-range code point) |
PooledArrayBuilderFixture |
Array members across the pooled builder's growth steps (every other array test uses 3 elements and never reaches one), for primitive, struct and reference element types; empty-array and builder-reuse semantics |
SerDeFixtureV2 |
Same feature set as SerDeFixture, exercised through a second serializer declaration to catch cross-class code-gen issues |
CoverageExpansionFixture |
All primitive types incl. decimal/Guid round-trips, nullable value-type omission on serialize, length-estimator accuracy, empty/null collections, CDATA strings (including concatenated blocks), HTML-entity-encoded string serialization |
SpecComplianceFixture |
XML 1.0 edge cases: unescaped > in attribute values (including a foreign-producer-style extra attribute during polymorphic deserialize), prolog processing instructions / DOCTYPE (incl. internal subset) being skipped, attribute-value whitespace normalization vs. character references |
Interop/* |
Differential harness: 36 POCO shapes run through both XmlSerDe and System.Xml.Serialization in all three directions (each reads the other's output; the two documents are compared). Divergences are pinned by tests as well, so closing one turns a test red on purpose. A compatibility table is written to interop-report.md next to the test assembly |
Interop/BinaryLexicalFixture |
base64Binary lexical edges that neither side writes and the differential runs therefore cannot produce: empty and self-closing elements decoding to byte[0] rather than null, whitespace inside the lexeme, a corrupt lexeme. Each is asserted as "both sides agree", not as "ours works" |
Compat/CompatFixture |
The facade at runtime: it is a System.Xml.Serialization.XmlSerializer, a refused type still round-trips through the fallback, a transitive graph is accelerated from a single call site, and every overload — including calls through the base-typed reference — produces a document the BCL can read |
Compat/CompatGeneratorFixture |
The facade at build time, driven through CSharpGeneratorDriver: which types are refused and why, that a refusal of one root leaves the others accelerated, that XmlSerDeCompatStrict changes severity only, and that a project without XmlSerDe.Compat gets no generated code at all |
You may also be interested in StackXML.