Skip to footer content
Iron Academy Logo
Learn C#
Learn C#

Other Categories

Async Zip in .NET 10: One Line Create or Extract

Tim Corey
~12m

Zip file operations in C# have always been possible through System.IO.Compression, but every call was synchronous, meaning the thread blocked until the archive was fully written or read. .NET 10 changes this with a set of async overloads that let you create, extract, and populate zip files without tying up the calling thread.

This walkthrough demonstrates the new async zip overloads in .NET 10, based on Tim Corey's recent guide. We'll look at three progressively detailed approaches: a one-liner to build an archive, a single call to unpack it, and a manual method for full control, while also examining a scoped using pitfall.

Setup: Paths and the Source Folder

[0:28 - 1:55] The setup begins with a console application targeting .NET 10 and a single using directive:

using System.IO.Compression;
using System.IO.Compression;

Three string variables define the paths used throughout the demo:

string sourceDirectory    = @"C:\temp\test";
string destinationZipFile = @"C:\temp\archive.zip";
string destinationDirectory = @"C:\temp\extracted";
string sourceDirectory    = @"C:\temp\test";
string destinationZipFile = @"C:\temp\archive.zip";
string destinationDirectory = @"C:\temp\extracted";

The verbatim string prefix (@) avoids the need to double up backslashes. sourceDirectory is the folder to zip up. destinationZipFile is the full path for the archive that will be created. destinationDirectory is where the contents will land when extracted.

One practical warning: never point destinationZipFile at a path inside sourceDirectory. Writing the zip into the folder being zipped causes a recursive read loop that breaks the process.

The test folder contains two files at the root and a third file inside a subfolder, which matters when exploring the includeBaseDirectory option and relative path handling in the manual approach.

Creating an Archive in One Line

[2:35 - 4:20] The async create call is a direct drop-in for the synchronous ZipFile.CreateFromDirectory:

await ZipFile.CreateFromDirectoryAsync(
    sourceDirectory,
    destinationZipFile,
    CompressionLevel.SmallestSize,
    includeBaseDirectory: false);
await ZipFile.CreateFromDirectoryAsync(
    sourceDirectory,
    destinationZipFile,
    CompressionLevel.SmallestSize,
    includeBaseDirectory: false);

CompressionLevel.SmallestSize prioritises the smallest output at the cost of a little extra processing time. CompressionLevel.Fastest does the reverse. For most development scenarios the difference is negligible, but on a web server processing many archives concurrently, the trade-off is worth evaluating.

includeBaseDirectory controls whether the source directory name appears as a root entry inside the archive. With false (the default), the zip opens directly to the files. Passing true instead places a test folder at the root, with the actual files sitting inside it. Most use cases benefit from setting this to false.

Extracting an Archive in One Line

[4:45 - 5:55] Extraction follows the same pattern:

await ZipFile.ExtractToDirectoryAsync(
    destinationZipFile,
    destinationDirectory,
    overwriteFiles: false);
await ZipFile.ExtractToDirectoryAsync(
    destinationZipFile,
    destinationDirectory,
    overwriteFiles: false);

destinationDirectory is created automatically if it does not already exist. The overwriteFiles parameter defaults to false, which throws an exception if any file in the archive already exists at the target path. Set it to true to replace existing files silently. Running the extraction twice illustrates this: the first pass succeeds and creates the folder, while the second throws an exception unless overwriteFiles: true is specified.

Selective Zipping: Adding Files One by One

[6:15 - 9:50] The one-liner approach zips an entire directory without filtering. When you need to include only specific files, you build the archive manually using a FileStream and ZipArchive:

await using FileStream zipStream = new FileStream(
    destinationZipFile,
    FileMode.Create,
    FileAccess.Write,
    FileShare.None,
    bufferSize: 4096,
    useAsync: true);

using ZipArchive archive = await ZipArchive.CreateAsync(
    zipStream,
    ZipArchiveMode.Create,
    leaveOpen: false,
    entryNameEncoding: null);
await using FileStream zipStream = new FileStream(
    destinationZipFile,
    FileMode.Create,
    FileAccess.Write,
    FileShare.None,
    bufferSize: 4096,
    useAsync: true);

using ZipArchive archive = await ZipArchive.CreateAsync(
    zipStream,
    ZipArchiveMode.Create,
    leaveOpen: false,
    entryNameEncoding: null);

A few parameters here are worth understanding. FileMode.Create overwrites any existing file at that path. FileMode.CreateNew throws instead if the file already exists. FileShare.None locks the file exclusively while the archive is being written, preventing other processes from reading or writing it mid-operation.

useAsync: true on the FileStream enables asynchronous I/O at the OS level. Note that setting this without actually using async calls downstream can slow the process down significantly, sometimes by as much as ten times. Because the entry-writing loop uses await, useAsync: true is the correct choice here. On a synchronous code path, leave it at the default false.

leaveOpen: false on the ZipArchive tells it to close and flush the underlying stream when it is disposed. entryNameEncoding: null keeps the default encoding, which the documentation recommends leaving unless you have a specific reason to change it.

With the archive ready, retrieve the source contents and write each entry:

string[] files = Directory.GetFiles(sourceDirectory, "*", SearchOption.AllDirectories);

foreach (string filePath in files)
{
    string relativePathAndName = Path.GetRelativePath(sourceDirectory, filePath);
    await archive.CreateEntryFromFileAsync(filePath, relativePathAndName);
}
string[] files = Directory.GetFiles(sourceDirectory, "*", SearchOption.AllDirectories);

foreach (string filePath in files)
{
    string relativePathAndName = Path.GetRelativePath(sourceDirectory, filePath);
    await archive.CreateEntryFromFileAsync(filePath, relativePathAndName);
}

Directory.GetFiles with SearchOption.AllDirectories retrieves files from nested subfolders, which is how the subfolder structure is preserved in the archive. The pattern argument ("*") is where you filter by extension: "*.txt" would limit the archive to text files, for instance.

Path.GetRelativePath strips the absolute prefix from each file path, leaving just the portion relative to sourceDirectory. This is what gets stored as the entry name inside the zip, reproducing the subfolder hierarchy faithfully. If you instead pass Path.GetFileName(filePath), every entry lands at the zip root regardless of its original location. That produces a flat archive, but risks a name collision if two entries share a filename across different subfolders.

The Scoped Using Pitfall

[9:50 - 11:30] If you try to add an extraction call after the manual zip block, you will hit a file-lock exception: The process cannot access the file because it is being used by another process. This happens because the using statement on zipStream uses file-scoped syntax, which means the stream stays open through the end of the file rather than closing at the zip block's brace. The extraction attempt runs while the lock is still held.

Converting to a braced form solves this:

// Before (file-scoped: stream stays open until end of file)
await using FileStream zipStream = new FileStream(...);

// After (block-scoped; stream released at the closing brace)
await using (FileStream zipStream = new FileStream(...))
{
    // zip operations here
}
// stream is now closed; extraction can proceed safely
// Before (file-scoped: stream stays open until end of file)
await using FileStream zipStream = new FileStream(...);

// After (block-scoped; stream released at the closing brace)
await using (FileStream zipStream = new FileStream(...))
{
    // zip operations here
}
// stream is now closed; extraction can proceed safely

Wrapping the zip work in braces and removing the trailing semicolon limits the stream's lifetime to that explicit scope. Once execution leaves the closing brace, the lock is released and a subsequent extraction call can open the same file without conflict.

Conclusion

[11:40 - end] The one-liners handle the common cases: CreateFromDirectoryAsync for archiving a whole folder and ExtractToDirectoryAsync for unpacking it. When you need control over which files enter the archive, the manual FileStream and ZipArchive approach gives you filtering, renaming, and path rewriting at the entry level.

To recap: add using System.IO.Compression, call await ZipFile.CreateFromDirectoryAsync or await ZipFile.ExtractToDirectoryAsync for straightforward cases, and reach for the manual FileStream/ZipArchive path when you need to filter or rename entries. Scope your using blocks with braces if the stream must be released before subsequent code runs. These additions make async/await patterns available throughout the full zip workflow in .NET 10.

Watch the full video on Tim Corey's YouTube channel to follow along with the live coding.

Hero Worlddot related to Async Zip in .NET 10: One Line Create or Extract
Hero Affiliate related to Async Zip in .NET 10: One Line Create or Extract

Earn More by Sharing What You Love

Do you create content for developers working with .NET, C#, Java, Python, or Node.js? Turn your expertise into extra income!

Iron Support Team

We're online 24 hours, 5 days a week.
Chat
Email
Call Me