# How to Stream JSON Lines With System.Text.Json in .NET 11

> Write an async sequence to a JSON Lines file with System.Text.Json in .NET 11. Read the records back and see why the built-in reader also accepts two records on the same line.

Published: 2026-11-13. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/dotnet-11-json-lines

[**.NET 11**](https://milanjovanovic.tech/blog/whats-new-dotnet-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**](https://dotnet.microsoft.com/en-us/platform/support/policy/dotnet-core).
You can copy either line into a JSON parser by itself.

```text
{"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**](https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-11/libraries#json-lines-output) 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.

```bash
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**](https://milanjovanovic.tech/blog/csharp-yield-return-statement) hands each release to the serializer as soon as the date has been parsed.

```csharp
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`.

```bash
dotnet run -- write
dotnet run -- read releases.jsonl
```

```text
.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.

```bash
tr '\n' ' ' < releases.jsonl > single-line.json
dotnet run -- read single-line.json
```

Both releases still print.
The [**stream overload added in .NET 9**](https://learn.microsoft.com/en-us/dotnet/api/system.text.json.jsonserializer.deserializeasyncenumerable?view=net-9.0) 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**](https://milanjovanovic.tech/blog/dotnet-11-zstandard-compression) 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.
