What Changed in I/O and Serialization from .NET 6 to .NET 10
Most of what arrived in I/O and serialization from .NET 6 to .NET 10 are ready-made APIs that replace hand-written loops, naming policies and checks: RandomAccess, ReadExactly, File.ReadLinesAsync, SnakeCaseLower, JsonUnmappedMemberHandling, JsonSerializerOptions.Web and Strict. We collect each version's additions with working before-and-after examples and the places where an upgrade silently changes behavior.
Version note: The code in this post was built and run with .NET 10; the "before" examples were also built and run in the same project.
Guess First
In the customer profile of Bookstore, the online bookstore used as the shared example across this site's series, the tags are a read-only property:
public sealed class CustomerProfile
{
public string Name { get; set; } = "";
public List<string> Tags { get; } = [];
}
If you deserialize the JSON {"Name":"Ada","Tags":["institutional"]} with the default settings, what is Tags.Count?
Second question: if you read a 1,000-line NDJSON file with one order per line using JsonSerializer.DeserializeAsyncEnumerable<Order>(stream), how many orders do you get?
Third: what does the line JsonSerializerOptions.Web.Converters.Add(new JsonStringEnumConverter()) do?
Throughout the series we used the APIs that .NET 10 offers. But most real projects come from .NET 6 or 8, and a good part of their code carries the solutions of those days. In this post we collect what came to I/O and serialization from .NET 6 to .NET 10, version by version, each with a short before-and-after example. The source is always Microsoft's what's new page for that version; for topics we covered in detail earlier in the series, we point to that post. The examples are in the Bookstore.IO.WhatsNew console app; the old ways also build and run in Before.cs:
dotnet run --project src/Bookstore.IO.WhatsNew -- net6
dotnet run --project src/Bookstore.IO.WhatsNew -- before
Five Versions at a Glance
| Version | I/O | Serialization |
|---|---|---|
| .NET 6 | FileStream rewrite, FileStreamOptions, RandomAccess, ZLibStream, CompressionLevel.SmallestSize | Source generator, JsonNode, IAsyncEnumerable support |
| .NET 7 | ReadExactly/ReadAtLeast, System.Formats.Tar, File.ReadLinesAsync | Contract customization, polymorphism, required |
| .NET 8 | ZipFile overloads that take a stream | Snake/kebab case, JsonUnmappedMemberHandling, populate, interface hierarchies, turning reflection off |
| .NET 9 | zlib-ng, ZLibCompressionOptions/BrotliCompressionOptions | JsonSerializerOptions.Web, JsonSchemaExporter, nullable annotations, indentation settings, multiple top-level values |
| .NET 10 | Async ZIP APIs, ZipArchive performance, concatenated GZip streams | Strict, AllowDuplicateProperties, ReferenceHandler in source generation, PipeReader support |
.NET 6: The FileStream Rewrite and the First Source Generator
FileStream was rewritten in .NET 6. According to Microsoft's notes the goal was performance and reliability, especially on Windows; a FileStream opened for async I/O no longer blocks at all on Windows. Two APIs came with the rewrite: FileStreamOptions, which moves the constructor's parameter list into an options object, and RandomAccess, which is offset based and does not use Position:
// .NET 5 and earlier
await using var old = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read, bufferSize: 4096, useAsync: true);
// .NET 6 and later
var options = new FileStreamOptions
{
Mode = FileMode.Create,
Access = FileAccess.Write,
Options = FileOptions.Asynchronous,
PreallocationSize = 1_000_000
};
await using var file = new FileStream(path, options);
PreallocationSize reserves space on disk but does not change the file's size: we wrote 337,659 bytes to a file opened with a 1 MB preallocation, and the file stayed exactly that size. To read two different places in the same file, you used to have to change the shared stream's Position inside a lock:
// Before .NET 6: Position + Read on a shared FileStream, under a lock.
lock (file)
{
file.Position = offset;
return file.Read(buffer, 0, buffer.Length);
}
// .NET 6 and later
using SafeFileHandle handle = File.OpenHandle(path);
await Task.WhenAll(
RandomAccess.ReadAsync(handle, first, fileOffset: 0).AsTask(),
RandomAccess.ReadAsync(handle, second, fileOffset: 1_000).AsTask());
RandomAccess @0: {"OrderNumber":"BS-0000001","C
RandomAccess @1000: 0141439471","Title":"Frankenst
The two reads run on the same handle at the same time, and nobody touches Position. The details, and the difference in async I/O between Windows and Linux, are in the series' FileStream post; reading a file in parallel with RandomAccess is in the large files post.
On the System.Text.Json (STJ) side, .NET 6 made three big contributions: the source generator with JsonSerializerContext, the mutable JsonNode DOM that joined the read-only JsonDocument, and IAsyncEnumerable support (DeserializeAsyncEnumerable):
JsonNode node = JsonNode.Parse(File.ReadLines(path).First())!;
node["Status"] = "Paid";
node.AsObject().Remove("Items");
JsonNode: {"OrderNumber":"BS-0000001","CustomerName":"Linus","CreatedAtUtc":"2026-11-01T00:00:07Z","Status":"Paid","Currency":"TRY","Total":1116.40}
For compression, .NET 6 added ZLibStream, which writes the ZLib format, and CompressionLevel.SmallestSize. Source generation and choosing a DOM are in the second of the series' System.Text.Json posts, compression levels in the archives post.
.NET 7: ReadExactly, Tar and Contract Customization
That Stream.Read can return fewer bytes than requested was the subject of the series' first post. Before .NET 7 every project solved it with the same loop:
// Before .NET 7: Read can return less than requested, a loop is required.
int total = 0;
while (total < buffer.Length)
{
int read = stream.Read(buffer, total, buffer.Length - total);
if (read == 0) throw new EndOfStreamException();
total += read;
}
// .NET 7 and later
stream.ReadExactly(buffer);
The same version brought ReadAtLeast, System.Formats.Tar for tar archives and File.ReadLinesAsync, which reads lines asynchronously:
// Before .NET 7
using var reader = new StreamReader(path);
while (await reader.ReadLineAsync() is not null) lines++;
// .NET 7 and later
await foreach (string line in File.ReadLinesAsync(path)) lines++;
In STJ, .NET 7 added three things: contract customization (changing properties in code with DefaultJsonTypeInfoResolver modifiers), attribute-based polymorphism and support for C# 11's required members. Before polymorphism, carrying derived types needed a custom converter that wrote the discriminator by hand; now two attributes are enough:
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(CardPayment), "card")]
[JsonDerivedType(typeof(BankTransfer), "bank-transfer")]
public abstract record PaymentMethod;
public sealed record CardPayment(string Last4) : PaymentMethod;
public sealed record BankTransfer(string Reference) : PaymentMethod;
Polymorphism: [{"type":"card","Last4":"4242"},{"type":"bank-transfer","Reference":"BS-0000042"}]
required: JsonException: JSON deserialization for type 'Bookstore.IO.WhatsNew.Net7+BookRequest' was missing required properties including: 'Isbn'.
The second line is the result of deserializing a request with a required string Isbn property without its Isbn. We covered all of these in detail with the PaymentMethod hierarchy in the first of the System.Text.Json posts.
.NET 8: Naming Policies, Unmapped Members and Populate
Before .NET 8, every project that produced or read snake_case JSON wrote its own JsonNamingPolicy:
// Before .NET 8: your own naming policy for snake_case.
public sealed class SnakeCasePolicy : JsonNamingPolicy
{
public override string ConvertName(string name) =>
string.Concat(name.Select((c, i) => i > 0 && char.IsUpper(c) ? "_" + char.ToLowerInvariant(c) : char.ToLowerInvariant(c).ToString()));
}
// .NET 8 and later
var snake = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };
SnakeCaseLower: {"order_number":"BS-0000042","created_at_utc":"1970-01-01T00:00:00Z"}
Hand-written policies easily get acronyms and digits wrong; the built-in SnakeCaseLower, SnakeCaseUpper, KebabCaseLower and KebabCaseUpper handle these cases consistently. To reject unknown fields we used to collect a dictionary with [JsonExtensionData] and check it by hand; .NET 8 turned that into a setting:
// Before .NET 8: JsonExtensionData to catch unknown fields.
[JsonExtensionData]
public Dictionary<string, JsonElement>? Unknown { get; set; }
// .NET 8 and later
var disallow = new JsonSerializerOptions { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow };
Disallow: JsonException: The JSON property 'Price' could not be mapped to any .NET member contained in type 'Bookstore.IO.WhatsNew.BookDto'.
Read-only Tags: default 0, Populate 1
The second line is the answer to the first question. With the default settings STJ does not touch Tags, which has no setter, and the value in the JSON is silently lost: the result is 0. .NET 8's PreferredObjectCreationHandling = JsonObjectCreationHandling.Populate setting (or the [JsonObjectCreationHandling] attribute on the property) fills the existing collection: the result is 1. The same version brought serialization of properties from interface hierarchies, turning off the reflection-based default with the JsonSerializerIsReflectionEnabledByDefault MSBuild property, and ZipFile.CreateFromDirectory/ExtractToDirectory overloads that take a stream instead of a file path.
.NET 9: Web Defaults, Schemas and zlib-ng
To use ASP.NET Core's default JSON settings (camelCase, case-insensitive matching, reading numbers from strings) in your own code, you used to define a static instance. .NET 9 made it a ready-made singleton:
// Before .NET 9: your own static instance for the web defaults.
public static readonly JsonSerializerOptions Web = new(JsonSerializerDefaults.Web);
// .NET 9 and later
JsonSerializer.Serialize(book, JsonSerializerOptions.Web);
Web: {"isbn":"9780441172719","title":"Dune","subtitle":null}
Web.Converters.Add: InvalidOperationException: This JsonSerializerOptions instance is read-only or has already been used in serialization or deserialization.
The answer to the third question is on the second line: JsonSerializerOptions.Web is read-only, and trying to change it throws. If you want to add a converter, create a copy with new JsonSerializerOptions(JsonSerializerOptions.Web) and change that. The same rule applies to Strict in .NET 10.
.NET 9's other STJ additions: JsonSchemaExporter, which produces JSON Schema from types, RespectNullableAnnotations, which honors C#'s nullable annotations, RespectRequiredConstructorParameters, which treats a missing constructor parameter as an error, IndentCharacter and IndentSize for indentation, and [JsonStringEnumMemberName], which changes the JSON name of enum members. And one related to the answer to the second question: reading multiple top-level JSON values from one stream.
// Before .NET 9: reading NDJSON line by line and deserializing each line separately.
await foreach (string line in File.ReadLinesAsync(path))
{
if (JsonSerializer.Deserialize(line, OrderContext.Default.Order) is not null) count++;
}
// .NET 9 and later
await foreach (Order? order in JsonSerializer.DeserializeAsyncEnumerable<Order>(stream, topLevelValues: true)) count++;
topLevelValues: 1,000 orders
Without topLevelValues: JsonException: The JSON value could not be converted to System.Collections.Generic.List`1[Bookstore.Ordering.Order]. Path: $ | LineNumber: 0 | BytePositionInLine: 1.
The answer to the second question: no orders arrive, an exception does. Without topLevelValues, DeserializeAsyncEnumerable expects the input to be a single JSON array; because the first line of the NDJSON is an object, it fails on the first byte. topLevelValues: true reads the input as a sequence of whitespace-separated values and creates no string per line. At the low level, the same feature is JsonReaderOptions.AllowMultipleValues for Utf8JsonReader.
On the I/O side, .NET 9's change is invisible but wide-reaching: DeflateStream, ZLibStream, GZipStream and ZipArchive use zlib-ng instead of zlib, and ZLibCompressionOptions and BrotliCompressionOptions arrived for level and strategy settings; we did the measurements in the archives post. A note from outside the series: since .NET 9, IHttpClientFactory uses SocketsHttpHandler by default and ties connection lifetime to the factory's handler rotation. The resilience side of that is in the External Service Integration and Resilience series.
.NET 10: Strict, PipeReader and Async ZIP
.NET 10's STJ additions are about safe defaults. The JSON standard does not say what to do when the same property arrives twice, and STJ takes the last one by default. AllowDuplicateProperties = false rejects it; JsonSerializerOptions.Strict bundles that, rejecting unknown fields, case-sensitive matching and .NET 9's two Respect* settings into one ready-made preset:
// Before .NET 10: turning on each setting one by one (no duplicate check).
var strict = new JsonSerializerOptions
{
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
RespectNullableAnnotations = true,
RespectRequiredConstructorParameters = true
};
// .NET 10 and later
JsonSerializer.Deserialize<BookDto>(json, JsonSerializerOptions.Strict);
Default: BookDto { Isbn = 9780441172719, Title = Emma, Subtitle = }
Strict: JsonException: Duplicate property 'Title' encountered during deserialization of type 'Bookstore.IO.WhatsNew.BookDto'.
We explained why a duplicate property is a security risk in the first of the System.Text.Json posts. The second addition is reference cycles in source generation: the source-gen context used to throw on a cycle; now ReferenceHandler can be given through the attribute:
// .NET 10: ReferenceHandler with source generation.
[JsonSourceGenerationOptions(ReferenceHandler = JsonKnownReferenceHandler.Preserve)]
[JsonSerializable(typeof(Category))]
public partial class CategoryContext : JsonSerializerContext;
ReferenceHandler: {"$id":"1","Name":"Fiction","Parent":null,"Children":{"$id":"2","$values":[{"$id":"3","Name":"Science Fiction","Parent":{"$ref":"1"},"Children":{"$id":"4","$values":[]}}]}}
The third is PipeReader support. Serializing to a PipeWriter was already possible; to read, you had to turn the PipeReader into a stream:
// Before .NET 10: turning the PipeReader into a Stream.
Order? order = await JsonSerializer.DeserializeAsync(reader.AsStream(), OrderContext.Default.Order);
// .NET 10 and later
Order? read = await JsonSerializer.DeserializeAsync(pipe.Reader, OrderContext.Default.Order);
PipeReader: BS-0000001, 3 items, 1,116.40 TRY
In the Pipelines post we saw how this fits with Kestrel's Request.BodyReader. On the I/O side, .NET 10 brought the async ZIP APIs (ZipArchive.CreateAsync, ZipArchiveEntry.OpenAsync, ZipFile.ExtractToDirectoryAsync and others), a ZipArchive that no longer loads all entries into memory in Update mode and, according to Microsoft's notes, a speed-up of up to about 35 percent when decompressing many small concatenated GZip streams. In the archives post we measured that the async ZIP APIs still write the end of each entry synchronously in Create mode and how that blows up on Kestrel; the fix is in .NET 11.
In Production
An upgrade is risky where it changes behavior without breaking the build. For a project moving from .NET 6 to 10, this checklist helps:
- Replace hand-written read loops with
ReadExactly/ReadAtLeast; small bugs in those loops (not checking for a zero return) are among the most common I/O bugs. - Find tests that compare the bytes of compressed output; with the zlib-ng switch in .NET 9 those bytes can change.
- Before replacing your own naming policies and "unknown field" checks done with
JsonExtensionDatawith the built-in settings, compare the outputs; the result can differ, especially for property names with acronyms. - Try
JsonSerializerOptions.Stricton endpoints that accept external JSON; but test first with the payloads your existing clients actually send, because a client that sends an extra field will now get an error. - When moving to the async ZIP APIs, test with streams that reject synchronous writes (Kestrel's response body).
Common Pitfalls
Trying to change the ready-made singletons. JsonSerializerOptions.Web, Default and Strict are read-only; take a copy.
Creating new JsonSerializerOptions on every request. This is not a new problem, but it was the reason for the pre-.NET 9 habit of "define your own static instance": an options object carries its own metadata cache, and creating a new one every time rebuilds that cache every time.
Taking NDJSON for an array. By default DeserializeAsyncEnumerable expects a single JSON array; for NDJSON, topLevelValues: true is required.
Read-only collections silently staying empty. A collection property without a setter is not filled with the default settings, and no error is raised. Either add a setter or turn on the populate behavior.
Saying "there is an async API, so everything is async". .NET 10's ZIP APIs showed the opposite; when you move to a new async API, test it with the streams of your real environment.
A Small Experiment
★☆☆ Compare Before.SnakeCasePolicy and JsonNamingPolicy.SnakeCaseLower with the property names ISBN13, HTTPStatus and OrderV2Id. Which gives which result?
★★☆ Mark CustomerProfile.Tags with the [JsonObjectCreationHandling] attribute so that only that property is populated under the default settings, and deserialize the same JSON again. What changes when you move the attribute to the class level?
★★★ Apply JsonSerializerOptions.Strict to an endpoint in Bookstore.Api: copy Strict's settings inside ConfigureHttpJsonOptions and keep the TypeInfoResolverChain. Send two requests, one with an extra field and one with a duplicate property; inspect the returned status code and response body.
In this post we collected what arrived up to .NET 10. In the next and final post we look at .NET 11, released on November 10, 2026: serialization of C# union types, support for writing JSON Lines, new in-memory streams, ZstandardStream and GZip's behavior change for empty payloads.