.NET 11 adds JsonSerializer.SerializeAsyncEnumerable for writing an async sequence as JSON Lines.
Pass topLevelValues: true and each item becomes a separate UTF-8 JSON value followed by a line feed.
A worker on .NET 9 or later can read the same file with DeserializeAsyncEnumerable and process the records one at a time.
What Goes Into a JSON Lines File?
This JSON Lines file contains two dates from Microsoft's .NET release history. You can copy either line into a JSON parser by itself.
{"version":"10.0","shipped":"2025-11-11"}
{"version":"11.0","shipped":"2026-11-10"}
The final record also ends in LF (0A in hexadecimal), including on Windows.
The JSON Lines writer ignores WriteIndented and NewLine in this mode so a nested object still occupies one physical line.
A newline inside a string is escaped as part of the JSON value.
Write the File From an Async Sequence
Create a console app and save the following two rows as releases.csv in its directory.
The input for this example has two fields and no header.
dotnet new console -n ReleaseLines -f net11.0
cd ReleaseLines
printf '10.0,2025-11-11\n11.0,2026-11-10\n' > releases.csv
Replace Program.cs with this exporter and reader.
The source reads one CSV row at a time.
yield return hands each release to the serializer as soon as the date has been parsed.
using System.Globalization;
using System.Text.Json;
var options = JsonSerializerOptions.Web;
if (args is ["write"])
{
using var output = File.Create("releases.jsonl");
await JsonSerializer.SerializeAsyncEnumerable(
output, ReadReleases("releases.csv"), topLevelValues: true, options: options);
}
else if (args is ["read", var path])
{
using var input = File.OpenRead(path);
await foreach (var release in JsonSerializer.DeserializeAsyncEnumerable<Release>(
input, topLevelValues: true, options: options))
{
Console.WriteLine($".NET {release.Version} shipped {release.Shipped:yyyy-MM-dd}");
}
}
else
{
Console.Error.WriteLine("Use 'write' or 'read <input-file>'.");
return 1;
}
return 0;
static async IAsyncEnumerable<Release> ReadReleases(string path)
{
await foreach (string line in File.ReadLinesAsync(path))
{
var fields = line.Split(',');
yield return new Release(fields[0],
DateOnly.ParseExact(fields[1], "yyyy-MM-dd", CultureInfo.InvariantCulture));
}
}
readonly record struct Release(string Version, DateOnly Shipped);
The two calls use the same web naming policy, so Release.Version maps to "version".
Run the commands from the project directory.
The write command creates or replaces releases.jsonl.
dotnet run -- write
dotnet run -- read releases.jsonl
.NET 10.0 shipped 2025-11-11
.NET 11.0 shipped 2026-11-10
The writer also accepts a PipeWriter and has overloads that take a generated JsonTypeInfo<T> contract.
If the receiving application expects an array, omit topLevelValues: true on both calls.
Does the Reader Enforce One Record Per Line?
Try replacing the line feeds with spaces and run the same reader.
tr '\n' ' ' < releases.jsonl > single-line.json
dotnet run -- read single-line.json
Both releases still print. The stream overload added in .NET 9 reads a sequence of top-level values separated by JSON whitespace. Two objects on the same line with a space between them satisfy that rule.
Suppose your importer has to tell the sender that line 17 failed. Read the file line by line and deserialize each line separately. Then you know which line failed, and you can keep reading if the sender accepts partial imports.
What Happens If an Export Stops Early?
A JSON Lines file can contain well-formed records even if the producer stops halfway through a batch. The three valid releases at the start look the same whether the export finished there or crashed just before the fourth. For a batch handoff, write to a temporary file in the destination directory and rename it after serialization and disposal succeed.
Streaming saves you from buffering the whole file. The consumer still needs enough memory to deserialize the largest string in a record. If you control the feed, agree on a maximum record size with the importer. For a large completed export, Zstandard compression in .NET 11 shows how to compress the file in chunks.
Summary
For a .NET worker reading a trusted export, await foreach over DeserializeAsyncEnumerable is enough.
Use a line reader when you need to reject two values on the same line or tell the sender which line to fix.
Frequently Asked Questions
How do you write JSON Lines in .NET 11?
Call JsonSerializer.SerializeAsyncEnumerable with an IAsyncEnumerable, an output Stream or PipeWriter, and topLevelValues set to true. The writer emits a compact UTF-8 JSON value followed by a line feed for each element.
Does the JSON Lines writer honor WriteIndented or NewLine?
In top-level-values mode it always uses compact JSON and terminates each record with LF, including the last record. Pretty-printing options and platform-specific newline settings do not change those bytes.
Can .NET 10 read a JSON Lines file produced by .NET 11?
Yes. The Stream overload of DeserializeAsyncEnumerable that accepts topLevelValues has been available since .NET 9. Set it to true and choose a type and naming policy that match the producer.

