C# & .NET (C# 14, .NET 10 LTS) Module 19 – Files, JSON, XML, dates, databases and HTTP
Streams, compression and System.IO.Pipelines
Stack streams in C#, compress with GZip and Brotli, catch truncated and oversized archives, and parse large input in flat memory with System.IO.Pipelines.
What you will learn
- Write text through a StreamWriter, a GZipStream and a FileStream, and dispose them so that the output is complete
- Choose GZip, Brotli or a ZIP archive, and a compression level, from measured sizes
- Detect truncated and oversized compressed input, and extract a ZIP archive without writing outside its folder
- Parse a large stream in flat memory with PipeReader, SequenceReader and AdvanceTo
Before you start
What you’ll learn · Before you start
What you will learn
- Write text through a StreamWriter, a GZipStream and a FileStream, and dispose them so that the output is complete
- Choose GZip, Brotli or a ZIP archive, and a compression level, from measured sizes
- Detect truncated and oversized compressed input, and extract a ZIP archive without writing outside its folder
- Parse a large stream in flat memory with PipeReader, SequenceReader and AdvanceTo
Before you start
On this page
A helper that gzips an export into memory returned 10 bytes for 132,227 bytes of CSV, and the 10 bytes decompressed
to nothing, with no exception anywhere. One using var in the wrong place caused it: a compressing
stream: A sequence of bytes read or written in pieces through the Stream class. FileStream and MemoryStream hold the bytes; wrapping streams such as GZipStream change them on their way to the stream they wrap. writes its last bytes only when it is disposed, and the helper read the result before
that.
You will stack streams for text and compression, choose between GZip and Brotli from measured sizes, and meet System.IO.Pipelines, which parses input in buffers it lends you instead of in strings you allocate.
Streams hand bytes to one another, and disposing finishes the job
stack.cs writes 10,000 fares as CSV through three streams, with a stream that counts bytes between each pair, then
compresses the same CSV into memory twice:
// Three streams stacked: a StreamWriter turns text into UTF-8 bytes, a GZipStream compresses them and a FileStream
// writes the result to a file. A counting stream between each pair shows how many bytes pass. Then the same CSV is
// compressed into memory twice: once with the GZipStream disposed too late, once in time.
using System.IO.Compression;
using System.Text;
Directory.CreateDirectory("out");
string rows = MakeCsv(10_000);
Console.WriteLine($"CSV text: {rows.Length:N0} characters");
var toFile = new CountingStream(File.Create(Path.Join("out", "fares.csv.gz")));
var toGzip = new CountingStream(new GZipStream(toFile, CompressionLevel.Optimal));
using (var writer = new StreamWriter(toGzip, new UTF8Encoding(false)))
writer.Write(rows);
Console.WriteLine($"into the GZipStream: {toGzip.Written:N0} bytes; into the FileStream: {toFile.Written:N0} bytes");
Console.WriteLine($"read back through FileStream, GZipStream and StreamReader: {CountLines(File.ReadAllBytes(Path.Join("out", "fares.csv.gz")))} lines");
byte[] data = Encoding.UTF8.GetBytes(rows);
byte[] late = CompressTooLate(data);
byte[] onTime = Compress(data);
Console.WriteLine($"disposed too late: {late.Length:N0} bytes, which decompress to {CountLines(late)} lines, and nothing threw");
Console.WriteLine($"disposed in time: {onTime.Length:N0} bytes, which decompress to {CountLines(onTime)} lines");
Console.WriteLine($"can seek? FileStream {File.OpenRead(Path.Join("out", "fares.csv.gz")).CanSeek}, GZipStream {new GZipStream(Stream.Null, CompressionMode.Decompress).CanSeek}");
// The bug: a using declaration disposes at the end of the method, after ToArray has copied the buffer.
static byte[] CompressTooLate(byte[] data)
{
var output = new MemoryStream();
using var gzip = new GZipStream(output, CompressionLevel.Optimal);
gzip.Write(data);
return output.ToArray();
}
// The fix: the using statement ends, and the GZipStream writes its last block and its footer, before ToArray.
static byte[] Compress(byte[] data)
{
var output = new MemoryStream();
using (var gzip = new GZipStream(output, CompressionLevel.Optimal, leaveOpen: true))
gzip.Write(data);
return output.ToArray();
}
static int CountLines(byte[] gzip)
{
using var reader = new StreamReader(new GZipStream(new MemoryStream(gzip), CompressionMode.Decompress));
int count = 0;
while (reader.ReadLine() is not null) count++;
return count;
}
static string MakeCsv(int count)
{
var text = new StringBuilder("from,to,fare\n");
string[] stations = ["PUNE", "NDLS", "MAS", "HWH", "SBC", "ADI"];
for (int i = 0; i < count; i++)
text.Append(stations[i % 6]).Append(',').Append(stations[(i * 5 + 1) % 6]).Append(',').Append(200 + (i * 37) % 1800).Append('\n');
return text.ToString();
}
// A stream that passes every write on to the stream under it and counts the bytes.
sealed class CountingStream(Stream inner) : Stream
{
public long Written { get; private set; }
public override void Write(byte[] buffer, int offset, int count) { Written += count; inner.Write(buffer, offset, count); }
public override void Write(ReadOnlySpan<byte> buffer) { Written += buffer.Length; inner.Write(buffer); }
public override void Flush() => inner.Flush();
protected override void Dispose(bool disposing) { if (disposing) inner.Dispose(); base.Dispose(disposing); }
public override bool CanRead => false;
public override bool CanSeek => false;
public override bool CanWrite => true;
public override long Length => throw new NotSupportedException();
public override long Position { get => Written; set => throw new NotSupportedException(); }
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
} Output
CSV text: 132,227 characters into the GZipStream: 132,227 bytes; into the FileStream: 13,002 bytes read back through FileStream, GZipStream and StreamReader: 10001 lines disposed too late: 10 bytes, which decompress to 0 lines, and nothing threw disposed in time: 11,489 bytes, which decompress to 10001 lines can seek? FileStream True, GZipStream False
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run stack.cs
The StreamWriter turned the text into 132,227 bytes of UTF-8 for the GZipStream, which passed 13,002 compressed bytes
to the FileStream. The StreamWriter is not a stream itself but a TextWriter, which turns characters into bytes for
the stream under it. The other two derive from Stream, with Read, Write and Seek, and CanRead, CanWrite
and CanSeek say which of them a stream supports: the FileStream could seek, the GZipStream could not, since
compressed data is read in order. Some streams are where the bytes end up, a FileStream in a file and a MemoryStream
in an array; others wrap a stream and change the bytes on their way to it, as a GZipStream compresses them and a
BufferedStream gathers them into larger pieces. Step through one Write call down the stack, and the dispose that
finishes it:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- One call. writer.Write(rows) receives the whole CSV as characters. The StreamWriter is not a stream: it turns characters into UTF-8 bytes and passes them to the stream below it.
- Bytes into the GZipStream. The encoded bytes reach the GZipStream in pieces. The counting stream above it saw every one of them, the same number of bytes as the UTF-8 text.
- Compressed bytes into the file. The GZipStream writes a header and compressed blocks to the FileStream as each block fills, but keeps its newest output, the tail of the data, until it is told that nothing more is coming.
- The using block ends. Disposing the StreamWriter flushes its last characters and disposes the GZipStream, because the writer owns it. Now the GZipStream writes its last block and its footer.
- The file is complete. The GZipStream disposes the FileStream it owns, which closes the file. Only now does the file hold a whole gzip stream, and reading it back gives every line.
A compressing stream keeps the tail of its output until it learns that nothing more is coming, and only Dispose
tells it. Disposing the writer disposes the streams it owns, unless one was made with leaveOpen: true.
Reading the result before the GZipStream is disposed
CompressTooLate declares its GZipStream with using var, which disposes at the end of the method, after
output.ToArray() has copied the buffer. It returned the 10-byte gzip header, which decompressed to 0 lines. End
the using statement first, as Compress does.
Checkpoint
Check: Stacked streams and disposing them
GZip is the format every gzip tool reads, Brotli makes text the smallest
sizes.cs compresses the same CSV with the four compressed formats of System.IO.Compression, at three levels:
// The same CSV compressed with GZip, Brotli, ZLib and Deflate at three levels, and each result decompressed again
// to check that nothing changed.
using System.IO.Compression;
using System.Text;
byte[] csv = Encoding.UTF8.GetBytes(MakeCsv(10_000));
Console.WriteLine($"CSV: {csv.Length:N0} bytes");
Console.WriteLine($"{"level",-13}{"GZip",9}{"Brotli",9}{"ZLib",9}{"Deflate",9}");
foreach (CompressionLevel level in new[] { CompressionLevel.Fastest, CompressionLevel.Optimal, CompressionLevel.SmallestSize })
{
int gzip = Size(s => new GZipStream(s, level));
int brotli = Size(s => new BrotliStream(s, level));
int zlib = Size(s => new ZLibStream(s, level));
int deflate = Size(s => new DeflateStream(s, level));
Console.WriteLine($"{level,-13}{gzip,9:N0}{brotli,9:N0}{zlib,9:N0}{deflate,9:N0}");
}
byte[] packed = Compress(s => new GZipStream(s, CompressionLevel.Optimal));
Console.WriteLine($"GZip Optimal is {(double)csv.Length / packed.Length:F1} times smaller; it starts with {packed[0]:X2} {packed[1]:X2} and decompresses to {Decompress(packed).Length:N0} bytes, same bytes: {Decompress(packed).AsSpan().SequenceEqual(csv)}");
// The same bytes handed to the compressor 1,024 at a time, as a StreamWriter with its default buffer hands them over.
var pieces = new MemoryStream();
using (var z = new GZipStream(pieces, CompressionLevel.Optimal, leaveOpen: true))
for (int at = 0; at < csv.Length; at += 1024) z.Write(csv, at, Math.Min(1024, csv.Length - at));
Console.WriteLine($"GZip Optimal, the CSV written in pieces of 1,024 bytes: {pieces.Length:N0} bytes");
int Size(Func<Stream, Stream> compressor) => Compress(compressor).Length;
byte[] Compress(Func<Stream, Stream> compressor)
{
var output = new MemoryStream();
using (Stream z = compressor(output)) z.Write(csv);
return output.ToArray();
}
static byte[] Decompress(byte[] gzip)
{
var output = new MemoryStream();
using (var z = new GZipStream(new MemoryStream(gzip), CompressionMode.Decompress)) z.CopyTo(output);
return output.ToArray();
}
static string MakeCsv(int count)
{
var text = new StringBuilder("from,to,fare\n");
string[] stations = ["PUNE", "NDLS", "MAS", "HWH", "SBC", "ADI"];
for (int i = 0; i < count; i++)
text.Append(stations[i % 6]).Append(',').Append(stations[(i * 5 + 1) % 6]).Append(',').Append(200 + (i * 37) % 1800).Append('\n');
return text.ToString();
} Output
CSV: 132,227 bytes level GZip Brotli ZLib Deflate Fastest 25,248 4,635 25,236 25,230 Optimal 11,489 2,596 11,477 11,471 SmallestSize 5,778 2,342 5,766 5,760 GZip Optimal is 11.5 times smaller; it starts with 1F 8B and decompresses to 132,227 bytes, same bytes: True GZip Optimal, the CSV written in pieces of 1,024 bytes: 12,996 bytes
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run sizes.cs
GZipStream writes the .gz format of RFC 1952: Deflate data in a frame whose footer holds a CRC-32 and the length. Its output starts with the bytes 1F 8B, which is how tools recognise a .gz file. ZLibStream and DeflateStream write the same data in a smaller frame or none. BrotliStream, the br format of RFC 7932, made the CSV the smallest, 2,596 bytes against GZip’s 11,489 at Optimal. SmallestSize spends more time for fewer bytes, Fastest the reverse, and how you feed a compressor counts too: the CSV written to GZip in pieces of 1,024 bytes made 12,996 bytes, close to the 13,002 of stack.cs, whose writer also hands its text over in pieces. A ZipArchive keeps several named files in one .zip. Answer two questions about your own output:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Format | Who can read it | Size of the CSV | Several files in one |
|---|---|---|---|
| GZip .NET type: GZipStream | Good .gz files of RFC 1952: gzip tools and gzip HTTP responses | Mixed 11,489 bytes at Optimal | Poor one stream of data, no names |
| Brotli .NET type: BrotliStream | Mixed the br format of RFC 7932: HTTP clients and servers | Good 2,596 bytes at Optimal, the smallest | Poor one stream of data, no names |
| ZIP archive .NET type: ZipArchive | Good .zip archives, opened by many tools on every system | Mixed Deflate inside each entry, so close to GZip | Good named entries in folders, each with its size |
| ZLib or Deflate .NET type: ZLibStream, DeflateStream | Poor formats used inside other formats, not files people open | Mixed 11,477 and 11,471 bytes at Optimal, the same data as GZip | Poor one stream of data, no names |
How the answers decide
- “One stream of data, or several files?” Several files: rules out GZip, Brotli and ZLib or Deflate. Only a ZIP archive keeps several named files, with their folders, in one file.
- “Who reads what you write?” People and any tool: rules out ZLib or Deflate and adds a caveat to Brotli. ZLib and Deflate are building blocks of other formats, and fewer tools open Brotli than open GZip.
- “Who reads what you write?” Browsers or HTTP clients and “One stream of data, or several files?” One stream: adds a caveat to GZip. HTTP clients accept both gzip and br; Brotli made the CSV the smallest, GZip is the one every client knows.
- “Who reads what you write?” Browsers or HTTP clients and “One stream of data, or several files?” One stream: rules out ZIP archive and adds a caveat to ZLib or Deflate. HTTP compresses a response body with a content coding such as br, gzip or deflate, the zlib format, which RFC 9110 notes some implementations send without its frame. A ZIP archive is not one.
- “Who reads what you write?” Only your own code and “One stream of data, or several files?” One stream: adds a caveat to ZIP archive. For one stream of data a ZIP archive only adds a list of entries; any of the stream formats will do.
Key idea
GZip for output that any tool may read, Brotli when the reader accepts it and the data is text, a ZIP archive for several files; the levels trade compression time for size.
Checkpoint
Check: GZip, Brotli and ZIP compared
System.IO.Pipelines lends you buffers instead of filling yours
A StreamReader reading a 200 MB log makes a string for every line, and each one is garbage as soon as its line is
parsed. With System.IO.Pipelines, a PipeReader: The reading end of System.IO.Pipelines. It reads a stream into buffers rented from a pool, returns the bytes not yet used, and is told with AdvanceTo how many were used and how many were looked at. reads into buffers rented from a pool,
hands you every byte you have not used yet, and AdvanceTo tells it how far you got. Step through one turn:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Ask for data. The loop calls ReadAsync on the PipeReader. It does not pass a buffer of its own: the reader owns all the memory.
- The reader fills a pooled buffer. The PipeReader rents a buffer from a pool and reads bytes from the stream into it. Nothing new is allocated for the data itself.
- Everything not yet used. ReadAsync returns a ReadOnlySequence of bytes: everything that has arrived and that the loop has not yet said it used. It can span several buffers.
- Parse where they lie. The loop finds the whole lines in the sequence and parses them in place. The last bytes are half a line, cut off where the read ended, so the loop leaves them.
- Say how far you got. AdvanceTo tells the reader two positions: consumed, the end of the last whole line, and examined, everything the loop has looked at. The reader can now reuse the memory of the consumed bytes.
- The next turn. The next ReadAsync waits for new bytes, then returns the half line followed by them, so the line that was cut in two is whole again. The loop never copied it.
The loop never owned a buffer, so it never had to copy a half line to the front of one, or grow one for a long line: the pipe keeps every byte it was not told to drop. A SequenceReader reads through those bytes in place, as a ReadOnlySequence<T>: A run of values that may lie in several buffers one after another, as a PipeReader returns bytes. A SequenceReader<T> reads through it in place, across the buffer boundaries. that may span several buffers. System.IO.Pipelines is part of the shared framework, so no package is needed.
Checkpoint
Check: One turn of a PipeReader loop
Truncated and oversized compressed input: failures reproduced, and the checks that catch them Pro: opens with a Pro, Premium or Ultimate pass
Compressed input from outside can be broken in ways that raise no exception at all. This part reproduces a file cut in half, a small file that grows a thousandfold and a ZIP archive with an entry that leaves its folder, and builds the check that stops each one.
In this part:
- A gzip file cut in half decompresses without an error
- A thousandfold expansion, stopped by a limit on the output
- A ZIP entry that climbs out of its folder
2 runnable examples (C#) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
A 200 MB log through StreamReader and PipeReader, measured, with the mistake that loses a line Pro: opens with a Pro, Premium or Ultimate pass
The step-through above showed one turn of a PipeReader loop. This part runs a whole loop over a 200 MB log against StreamReader.ReadLine, measures what each allocates, and reproduces the most common mistake of such loops.
In this part:
- The same log read three ways, measured
- What consumed and examined mean, and the loop that loses its last line
- Three settings that decide how a pipe behaves
1 runnable example (C#) · about 4 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
A lab: gzip CSV to gzip NDJSON in one pass, inside a memory budget Pro: opens with a Pro, Premium or Ultimate pass
Each part of this lesson showed one piece: stacked streams, compression, a pipe loop. This lab joins them into one converter that reads compressed CSV and writes compressed NDJSON, and checks it against a memory budget.
In this part:
- The converter and its six checks
- How the budget is met, piece by piece
1 runnable example (C#) · about 4 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Practise
Three ideas of this lesson are checked below, each in a few questions: stacking and disposing streams, choosing a format, and the turn of a pipe loop.
Practice that adapts to you
Practise this lesson
7 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What does leaveOpen do when you create a GZipStream or a StreamReader?
By default a wrapping stream owns the stream it wraps: disposing a GZipStream or a StreamReader also disposes the stream
under it. leaveOpen: true changes that, so the inner stream stays open and usable. You need it when the inner stream
is not yours to close, such as a stream your caller passed in, or when you still need it afterwards, such as a
MemoryStream whose bytes you read after the GZipStream has been disposed and has written its footer.
-
Mid to senior
A partner's .NET importer reads your nightly export, a .csv.gz file of about 2 million rows. On some nights it imports far fewer rows than the export holds, and nothing on either side logs an error. How would you find the cause, and what would you change so that a short file can never pass as a complete one?
-
Mid to senior
A service accepts ZIP uploads of invoices and extracts them with ZipFile.ExtractToDirectory into a shared folder. One upload of 40 KB filled the disk, and another left files in the folder although its extraction failed. Explain both and redesign the extraction.
-
Senior to staff
A service parses 2 GB log uploads with StreamReader.ReadLine and string.Split. While an upload is parsed, the p99 latency of every other request jumps, and the traces show long garbage-collection pauses. How would you rework the parser, and how would you prove that the change helped?
Key takeaways
- Streams stack: a writer turns text into bytes and each wrapping stream passes them on, and a GZipStream writes its last block and footer only when disposed, so read the result after the using statement ends.
- GZip’s .gz format is the one tools read everywhere, Brotli made the CSV smallest, and a ZipArchive holds several named files.
- A PipeReader lends you pooled buffers and
AdvanceTosays how far you got, so a parser works on bytes where they lie.
Terms in this lesson
- PipeReader
- The reading end of System.IO.Pipelines. It reads a stream into buffers rented from a pool, returns the bytes not yet used, and is told with AdvanceTo how many were used and how many were looked at.
- ReadOnlySequence<T>
- A run of values that may lie in several buffers one after another, as a PipeReader returns bytes. A SequenceReader<T> reads through it in place, across the buffer boundaries.
- stream
- A sequence of bytes read or written in pieces through the Stream class. FileStream and MemoryStream hold the bytes; wrapping streams such as GZipStream change them on their way to the stream they wrap.
References
- Stream class (Microsoft Learn)
- GZipStream class (Microsoft Learn)
- BrotliStream class (Microsoft Learn)
- CompressionLevel enum (Microsoft Learn)
- How to compress and extract files (Microsoft Learn)
- ZipFile.ExtractToDirectory method (Microsoft Learn)
- What's new in .NET libraries for .NET 10 (Microsoft Learn)
- System.IO.Pipelines in .NET (Microsoft Learn)
- SequenceReader<T> struct (Microsoft Learn)
- Utf8JsonWriter class (Microsoft Learn)
- RFC 1952: GZIP file format specification version 4.3 (IETF)
- RFC 1950: ZLIB Compressed Data Format Specification version 3.3 (IETF)
- RFC 7932: Brotli Compressed Data Format (IETF)
- RFC 9110: HTTP Semantics, content codings (IETF)
- File Upload Cheat Sheet (OWASP Cheat Sheet Series)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress