← All articles

Creating ZIP files with owned streams

Small multi-file downloads can be assembled in a stream without creating a temporary ZIP on disk. Correct disposal order matters as much as creating entries.

t.Turgay
Creating ZIP files with owned streams

Offering several report files as one download does not always require a temporary directory, a cleanup task and a second file read. Small outputs can be assembled directly in a ZIP stream. The subtle part is resource ownership: an entry, the archive and the underlying stream each have a different lifetime.

Written bytes are not yet a completed archive

A ZIP file contains directory information as well as file contents. Writing some bytes to a stream is therefore different from completing a valid downloadable archive. Run this example in a .NET 10 console project: it creates two text entries and then reopens the output to inspect their names. It needs no additional package or files from a real project.

using System.IO.Compression;
using System.Text;
using var output = new MemoryStream();
using (var zip = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
{
    foreach (var file in new[] { (Name:"readme.txt", Text:"hello"), (Name:"notes.txt", Text:"world") })
    {
        var entry = zip.CreateEntry(Path.GetFileName(file.Name));
        using var writer = new StreamWriter(entry.Open(), new UTF8Encoding(false));
        writer.Write(file.Text);
    }
}
output.Position = 0;
using var check = new ZipArchive(output, ZipArchiveMode.Read);
Console.WriteLine(string.Join(",", check.Entries.Select(e => e.FullName)));

There are three nested lifetimes. A StreamWriter writes one entry and is disposed during that loop iteration. ZipArchive covers all entries and finishes the archive when its using block ends. MemoryStream remains usable because leaveOpen: true prevents the archive from closing it. Closing the outer stream too early would make the later seek and read fail.

Understand the stream position and copying cost

After writing, the MemoryStream position is at the end. Setting Position to zero allows the same bytes to be read from the beginning; it does not create or repair the archive. Some APIs accept byte[] rather than Stream. Calling ToArray creates another copy of the contents, which can become a significant extra allocation for a large report.

The StreamWriter here produces UTF-8 text. Existing PDFs, images or spreadsheets should instead be copied as bytes from their source stream into the entry stream, for example with CopyToAsync. They should not be decoded as text and written back through StreamWriter. Encoding is part of a text file's contract, not a universal transformation for every ZIP entry.

Give archive entries a deliberate naming policy

Path.GetFileName removes directory portions in this example. It does not invent unique names for two files that share the same basename. I would choose a stable naming scheme such as report type plus record identity. An archive that opens correctly can still be an incorrect deliverable if a user cannot distinguish two expected documents.

Creating an archive from controlled input is also different from extracting an uploaded ZIP. Extraction needs validation that each resolved destination remains inside the permitted directory. This creation example should not be read as permission to treat arbitrary entry names as trustworthy filesystem paths.

Choose memory or streaming based on the output

Building a small ZIP in memory lets the operation finish before an HTTP response is sent. For a large archive, holding all compressed bytes and possibly another array copy can be expensive. Writing directly to the response reduces buffering, but once headers have been sent a later error cannot simply become a normal JSON error response. A temporary file or background generation job may provide a clearer failure boundary for large reports.

Cancellation should travel into the underlying reads and writes. Continuing to generate a large report after the client disconnects wastes work. If a generated archive is stored for later downloads, retention and download authorization need explicit rules as well.

Read the output as part of verification

The expected entry names are readme.txt and notes.txt. A useful test also opens each entry and verifies hello and world. Empty input, Unicode text and duplicate filenames deserve separate cases. Simulating a write failure is valuable too: the important outcome is that an unfinished archive is not presented as a successful download.

ZipArchive lifecycle; Stream.CopyToAsync.

✳ You have reached the end of this orbit.
Explore more articles →
turgay.A little code, a few pixels, a lot of curiosity.© 2026 · Made on Earth ✳