I/O and Serialization in .NET 11: Union Serialization, JSON Lines, In-Memory Streams and Zstandard
In .NET 11, System.Text.Json serializes C# 15 unions without a wrapper and writes JSON Lines with SerializeAsyncEnumerable; System.IO.Compression gained Zstandard, and ZIP gained passwords and CRC32 validation. We show the new in-memory streams, closed hierarchy polymorphism and two behavior changes that can break old tests (an empty GZip is now 20 bytes, a corrupted zip entry now throws), compared with .NET 10.
Version note: The code in this post was built and run with .NET 11 (SDK 11.0.100) and C# 15; the comparisons were also run with .NET 10.
Guess First
The export job of Bookstore, the online bookstore used as the shared example across this site's series, also gzips the order file of an empty day:
var output = new MemoryStream();
using (new GZipStream(output, CompressionLevel.Optimal, leaveOpen: true)) { }
Console.WriteLine($"Runtime {Environment.Version}: empty GZip payload -> {output.Length} bytes");
How many bytes does this code write on .NET 10, and how many on .NET 11?
Second question: we corrupt a single byte in a zip entry stored without compression (the letter B in the invoice number becomes X) and read the entry. What does .NET 10 do?
Third: if you define public union OrderReference(int, string); with C# 15's union types and serialize the value 42, what do you see in the JSON: 42, or a wrapper such as {"$type":"int","value":42}?
.NET 11 and C# 15 were released on November 10, 2026. In this post we look at what is new on the I/O and serialization side; the source is Microsoft's what's new page, updated for GA. Because the series' base is .NET 10, the code for this post lives in a single project, Bookstore.IO.Net11; it targets net11.0 and needs the .NET 11 SDK. With this project added, building the whole solution now requires the .NET 11 SDK; the .NET 10 SDK fails with NETSDK1045, and you can keep building the other projects individually.
dotnet run --project src/Bookstore.IO.Net11 -- json
Union Serialization
C# 15's union types express in the language itself that a value can be one of several types. The compiler provides an implicit conversion from each case type to the union and checks that a switch covers all cases. In .NET 11, System.Text.Json (STJ) serializes unions directly:
// C# 15 union: an order reference is either a numeric ID or a "BS-..." number.
public union OrderReference(int, string);
OrderReference byId = 42, byNumber = "BS-0000042";
Console.WriteLine($"Union: {JsonSerializer.Serialize(byId)} and {JsonSerializer.Serialize(byNumber)}");
OrderReference read = JsonSerializer.Deserialize<OrderReference>("\"BS-0000007\"");
string text = read switch
{
int id => $"id {id}",
string number => $"number {number}"
};
Union: 42 and "BS-0000042"
Union read: number BS-0000007
The answer to the third question: 42. The serializer writes the active case directly, without a wrapper or $type. When reading, it picks the case by the kind of JSON token: a number selects int, a string selects string. This removes the need for a custom converter where an API accepts a number in one place and text in another for the same field.
If both cases are objects, the token kind is not enough. The payment service's result is either an approval or a decline:
public sealed record PaymentApproved(string AuthorizationCode, decimal Amount);
public sealed record PaymentDeclined(string Reason, int RetryAfterSeconds);
public union PaymentResultByKind(PaymentApproved, PaymentDeclined);
[JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))]
public union PaymentResult(PaymentApproved, PaymentDeclined);
Without classifier: JsonException: JSON value type 'Object' is ambiguous for union type 'Bookstore.IO.Net11.PaymentResultByKind' because multiple case types can use this value type. Specify a custom type classifier to support deserialization. Path: $ | LineNumber: 0 | BytePositionInLine: 1.
Structural classifier: PaymentDeclined { Reason = insufficient_funds, RetryAfterSeconds = 3600 }
Union write: {"AuthorizationCode":"A-1001","Amount":349.90}
Without a classifier, the union states the ambiguity plainly. JsonUnionTypeStructuralClassifier picks the case by looking at the root-level property names: Reason and RetryAfterSeconds exist only on PaymentDeclined. According to the documentation, this classifier does not look at property values or nested content; if the payload matches zero or several cases, it throws JsonException. For more complex rules you can derive from JsonTypeClassifierFactory and write your own classifier. There is no discriminator when writing either; the side receiving the JSON still has to infer the case from the shape. That makes unions convenient for external APIs whose contract you do not control; in your own API, the polymorphism with a discriminator that we saw in the first of the series' System.Text.Json posts can be a clearer contract.
Closed Hierarchies, JSON Lines and Other STJ Additions
C# 15's closed hierarchies fix the list of derived types at compile time. STJ can use that for polymorphism: instead of writing [JsonDerivedType] attributes one by one, you turn on InferClosedTypePolymorphism:
// C# 15 closed hierarchy: the derived types are known at compile time.
[JsonPolymorphic(InferClosedTypePolymorphism = true)]
public closed record ShippingOption;
public sealed record StandardShipping(int Days) : ShippingOption;
public sealed record ExpressShipping(DateOnly DeliverBy) : ShippingOption;
Closed hierarchy: [{"$type":"StandardShipping","Days":3},{"$type":"ExpressShipping","DeliverBy":"2026-11-16"}]
The type name is used as the discriminator. This has a consequence: if you rename the class, your JSON contract changes as well. In a public API it is still safer to give the discriminators explicitly with [JsonDerivedType(typeof(StandardShipping), "standard")]; if there are explicit registrations, they are used instead of the inferred list.
The topLevelValues parameter that came to DeserializeAsyncEnumerable in .NET 9 has come to the writing side in .NET 11. SerializeAsyncEnumerable with topLevelValues: true produces JSON Lines:
await using (var file = File.Create(path))
{
await JsonSerializer.SerializeAsyncEnumerable(file, Orders(1_000), topLevelValues: true);
}
JSON Lines: 337,659 bytes, last byte 0x0A
{"OrderNumber":"BS-0000001","CustomerName":"Linus","CreatedA...
read back 1,000 orders
Each value is written compactly on one line, and a \n follows the last value too. According to the documentation, the WriteIndented and NewLine settings are ignored in this mode; each value has to be one line. In the series' Pipelines and System.Text.Json posts we wrote NDJSON line by line by hand; now both directions are a single call.
The other STJ additions are smaller but touch everyday work. [JsonNamingPolicy] and [JsonIgnore] can now be given at the type level; JsonNamingPolicy.PascalCase was added; Utf8JsonWriter.Reset allows reusing a writer with another stream and other options:
[JsonNamingPolicy(JsonKnownNamingPolicy.SnakeCaseLower)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public sealed class StockAlert
{
public string Isbn { get; set; } = "";
public int QuantityOnHand { get; set; }
public string? WarehouseNote { get; set; }
}
// The same writer is reused with another target and other options.
writer.Reset(second, new JsonWriterOptions { Indented = false });
Type-level attributes: {"isbn":"9780441172719","quantity_on_hand":2}
Utf8JsonWriter.Reset: 29 bytes indented, then {"isbn":"9780141439587"}
WarehouseNote was not written because it is null, and the names became snake_case without touching the options. The source generator supports more shapes too: private members marked with [JsonInclude], inaccessible [JsonConstructor]s, and init properties that keep their initializer value when the JSON does not contain them are some of them.
In-Memory Streams
If the data you have is a string, a ReadOnlyMemory<byte> or a ReadOnlySequence<byte> from Pipelines, and you need to pass it to an API that wants a Stream, the solution so far was to produce a byte[] first and wrap it in a MemoryStream. .NET 11 brings four types that make that copy unnecessary:
// A Stream from a string without producing a byte[].
using (var stream = new StringStream(json, Encoding.UTF8))
{
Order order = (await JsonSerializer.DeserializeAsync<Order>(stream))!;
}
using (var stream = new ReadOnlyMemoryStream(payload))
{
Order order = (await JsonSerializer.DeserializeAsync<Order>(stream))!;
}
// A multi-segment ReadOnlySequence from a Pipe becomes a stream without being copied into one array.
using (var stream = new ReadOnlySequenceStream(result.Buffer))
{
Order order = (await JsonSerializer.DeserializeAsync<Order>(stream))!;
}
byte[] fixedBuffer = new byte[64];
using (var stream = new WritableMemoryStream(fixedBuffer))
{
JsonSerializer.Serialize(stream, SampleOrders.Generate(1).Single());
}
StringStream: BS-0000001, CanSeek False
ReadOnlyMemoryStream: BS-0000001, Length 430, CanWrite False
ReadOnlySequenceStream: BS-0000001, single segment False
WritableMemoryStream (64 bytes): NotSupportedException: Unable to expand length of this stream beyond its capacity.
There are a few details to know. StringStream encodes the string as it is read and is not seekable; keep that in mind if you pass it to an API that wants to know the length up front. ReadOnlySequenceStream is not in System.IO but in the System.Buffers namespace; the what's new page does not mention the namespace, and we did not find it on the first try. It is not seekable either. WritableMemoryStream stays at the size of the buffer you give it and does not grow: writing a 430-byte order into a 64-byte buffer threw an exception. It is useful where you write into a buffer from the stack or a pool and want an overflow to be an error.
There are two additions in low-level I/O. SafeFileHandle.Type tells you whether a handle is a file, a pipe or a socket without platform-specific code; RandomAccess.Read/Write now also work with non-seekable handles such as pipes (the offset is not used in that case):
SafeFileHandle.CreateAnonymousPipe(out SafeFileHandle readEnd, out SafeFileHandle writeEnd, asyncRead: false, asyncWrite: false);
// .NET 11: RandomAccess also works with non-seekable handles; the offset is ignored.
RandomAccess.Write(writeEnd, "BS-0000042\n"u8, fileOffset: 0);
int read = RandomAccess.Read(readEnd, received, fileOffset: 0);
SafeFileHandle.Type: file RegularFile, pipe Pipe
RandomAccess over a pipe: 11 bytes, BS-0000042
On the System.IO.Pipelines side the API did not change; according to Microsoft's notes, lock contention was reduced for workloads where many writers and readers run at the same time.
Compression: Zstandard and Span-Based Encoders
Zstandard is now in System.IO.Compression, next to GZipStream and BrotliStream: ZstandardStream, ZstandardEncoder/ZstandardDecoder and ZstandardDictionary for dictionary training. We compared it on the 6.8 MB NDJSON from the archives post, on a developer machine:
Input: 6,808,653 bytes NDJSON
GZip Optimal 326,007 bytes 14.5 ms
Brotli quality 9 294,010 bytes 61.4 ms
Zstandard Fastest 513,109 bytes 3.8 ms
Zstandard Optimal 490,731 bytes 4.0 ms
Zstandard quality 19 238,690 bytes 1,392.9 ms
ZLibEncoder.TryCompress: True, 325,840 bytes, no Stream
Both ends are interesting. With CompressionLevel.Optimal, Zstandard is almost four times as fast as GZip but the file is 50 percent larger; this shows the enum maps to the speed side for Zstandard. Quality 19, on the other hand, produced a slightly smaller file than Brotli quality 11 (242,577 bytes and 7 seconds in the previous post) in a fifth of the time. As with Brotli, choose the level with ZstandardCompressionOptions.Quality for your own data instead of the enum. Zstandard can also be used as a Content-Encoding in HTTP, but do not add it to response compression without checking client support.
The last line is from the new span-based APIs: DeflateEncoder, ZLibEncoder, GZipEncoder and their decoders bring BrotliEncoder's model to the Deflate family. Compressing a buffer with a single call without creating a stream is an allocation-free route for protocol parsers and middleware that already work with spans.
ZIP and Tar: Passwords, Access Modes and Format Selection
.NET 11 brought password support to ZIP entries. New CreateEntry overloads take a password and an encryption method; for reading, the Open overload that takes a password is used:
using (var archive = new ZipArchive(encrypted, ZipArchiveMode.Create, leaveOpen: true))
using (var writer = new StreamWriter(archive.CreateEntry("invoice.xml", "example-password", ZipEncryptionMethod.Aes256).Open()))
{
writer.Write("<Invoice />");
}
using var reader = new StreamReader(entry.Open(FileAccess.Read, "example-password"));
Encrypted: True, Aes256
Open without password: InvalidDataException: A password is required for encrypted entries.
Open with password: <Invoice />
ZipEncryptionMethod includes AES 128, 192 and 256 and the old ZipCrypto. ZipCrypto is an old method considered weak; use it only if you need compatibility with old tools that expect it. Do not keep the password in code; the constant text in this example is for demonstration only. Entries can now be opened with an explicit access mode through Open(FileAccess), and the CompressionMethod property tells you whether an entry is Stored, Deflate or Deflate64.
On the tar side, TarFile.CreateFromDirectory now takes a format; it used to always produce Pax:
TarFile.CreateFromDirectory(dir, tar, includeBaseDirectory: false, format: TarEntryFormat.Gnu);
Tar format: Gnu
The async ZIP bug we measured in the archives post is fixed in .NET 11 as well: the same counting stream showed zero synchronous writes on .NET 11. When you move to .NET 11, you can delete the AllowSynchronousIO line in the /api/invoices/zip endpoint.
Behavior Changes: Empty Payloads and CRC32
Two changes can break old tests without a build error. The first is the answer to the first question. We ran the same code on two runtimes:
Runtime 10.0.13: empty GZip payload -> 0 bytes
Runtime 11.0.0: empty GZip payload -> 20 bytes
On .NET 11, DeflateStream and GZipStream write a header and footer even when no data is written; an empty GZip is now 20 bytes, an empty Deflate 2 bytes. According to Microsoft's breaking change note, the reason is tools that do not accept empty output as a valid compressed stream. Tests that check that an empty export has a file size of 0, or a "do not send the file if it is empty" rule, break with this change. If you need the old behavior, handle empty content yourself before compressing.
The second is the answer to the second question. We read the entry with the corrupted byte on two runtimes:
Corrupted entry read: <Invoice><ID>XSI20260000042</ID></Invoice>
Runtime 11.0.0: method Stored
Corrupted entry: InvalidDataException: The CRC32 checksum of the extracted data does not match the expected value from the archive.
The first line is .NET 10: the corrupted data was read without any warning, and the invoice number became XSI.... .NET 11 verifies the entry's CRC32 checksum and throws InvalidDataException on a mismatch. This is a security and data integrity improvement, but broken archives that used to "work" will now throw. Code that processes uploaded zips needs to catch this exception and return a meaningful error to the user.
In Production
.NET 11 is an STS release; .NET 10 is an LTS release. That is why many teams will stay on .NET 10 in production and try .NET 11 in new projects or at their own pace. When upgrading, look at these on the I/O side:
Empty-output assumptions. Find the tests and checks that assume an empty compressed file is 0 bytes.
Broken archives. Expect InvalidDataException to arrive more often in any code that reads ZIPs; especially for user uploads, turn it into a 400 response.
Fix the JSON shape before using unions and closed hierarchies in external contracts. Make sure clients that read unions without a discriminator can infer the case from the shape, and that the type-name discriminators of a closed hierarchy survive renaming.
Measure the level choice. Zstandard's Optimal and GZip's Optimal do not mean the same thing; when carrying enum values across algorithms, compare size and time with your own data.
Common Pitfalls
Leaving a union with two object cases without a classifier. Serialization works fine; the first deserialization throws. Test both directions.
Looking for ReadOnlySequenceStream in System.IO. It is in the System.Buffers namespace.
Assuming WritableMemoryStream grows. It has as much room as the buffer you give it; an overflow is a NotSupportedException.
Trying to build the whole solution with the .NET 10 SDK. Even a single project targeting net11.0 stops the solution build with NETSDK1045; update the SDK version in CI or build the project separately.
Dismissing the empty payload and CRC32 changes as "does not affect us". Both show up at run time, not at build time.
A Small Experiment
★☆☆ Add a third case to OrderReference: Guid. How do serialization and deserialization behave? What happens because Guid and string use the same kind of JSON token?
★★☆ Rewrite the NDJSON production from the series' Pipelines post with SerializeAsyncEnumerable(..., topLevelValues: true) and verify that the output is identical byte for byte. If there is a difference, where is it?
★★★ Port SafeZipExtractor from the archives post to .NET 11: add a password parameter for encrypted entries, turn a CRC32 error into a meaningful error that includes the entry name, and use the Open(FileAccess.Read) overload.
That completes the I/O in .NET series: files, streams and serialization, from the Stream model to Zstandard. Which serialization format to choose for message and cache data is covered in the Working with Message Queues and Caching series; the serialization and compression measurements we made in this series help you evaluate those choices with your own data.