Skip to the content.

How to do a curl -o file download in C#

To download a file in C# the way curl -o does, run curl -o <path> <url> with Curl.ExecuteAsync, or call Curl.DownloadAsync(url, path). The bytes are written exactly as received, so images, PDFs, ZIPs and Office files are not corrupted.

dotnet add package CurlDotNet

The curl command

using CurlDotNet;

var result = await Curl.ExecuteAsync(
    "curl -L -o logo.png https://httpbin.org/image/png");

Console.WriteLine(result.StatusCode);              // 200
Console.WriteLine(string.Join(", ", result.OutputFiles));  // logo.png
Console.WriteLine(new FileInfo("logo.png").Length);       // bytes on disk

Add -L when the download URL redirects (most CDN and release links do). -O saves under the remote file name (the last URL path segment) in the current directory.

With -o or -O, result.Body is empty, because the curl CLI prints nothing to standard output when it writes to a file. Read the file, use result.OutputFiles, or (for a binary response) result.BinaryData. Add -i to have the header block written into the file before the body, as curl does.

More output options

The curl CLI’s file-output flags work the same way:

Flag What it does
-J / --remote-header-name (with -O) Use the file name from the Content-Disposition header; never overwrites an existing file
--output-dir <dir> Directory for -o / -O files
--create-dirs Create missing directories in the output path
--no-clobber Do not overwrite: save under a numbered name (report-1.txt, report-2.txt, …) instead
--skip-existing Skip the download when the output file already exists
-R / --remote-time Set the file’s modification time from Last-Modified
--remove-on-error Delete a partially written file when the transfer fails
-C <offset> / -C - Resume: -C - continues from the existing file’s size and appends to it
--max-filesize <bytes> Refuse larger files (CurlFileSizeExceededException, exit 63)
-D <file> / -D - Save the response headers to a file, or put them in result.Body
--etag-save / --etag-compare Download only when the resource’s ETag changed
using CurlDotNet;

var result = await Curl.ExecuteAsync(
    "curl -L --create-dirs --output-dir downloads/2026 -O https://httpbin.org/image/png");

Console.WriteLine(string.Join(", ", result.OutputFiles));  // downloads/2026/png

A missing output directory without --create-dirs throws CurlWriteErrorException (exit 23). -C - throws CurlRangeErrorException (exit 33) when the server ignores the range request.

Helper method and fluent builder

using CurlDotNet;
using CurlDotNet.Core;

await Curl.DownloadAsync("https://httpbin.org/image/jpeg", "photo.jpg");

var result = await CurlRequestBuilder
    .Get("https://httpbin.org/image/png")
    .FollowRedirects()
    .WithOutput("logo.png")
    .ExecuteAsync();

Why it is binary-safe

CurlDotNet always reads the response as raw bytes first, then decides whether it is text. Known binary MIME types (images, audio, video, fonts, PDF, ZIP, Office Open XML such as .xlsx and .docx) stay binary, and a missing Content-Type is treated as binary. Even a text/* response is checked byte by byte, so a file served with a misleading text type is not decoded into a corrupted string.

For binary responses result.IsBinary is true, result.BinaryData holds the bytes and result.Body is empty.

Keep the bytes in memory instead

Skip -o and work with BinaryData directly, or save later:

using CurlDotNet;

var result = await Curl.ExecuteAsync("curl https://httpbin.org/image/png");
if (result.IsSuccess && result.IsBinary)
{
    byte[] png = result.BinaryData;
    result.SaveToFile("copy.png");
    Console.WriteLine($"{png.Length} bytes");
}

result.ToStream() gives you a Stream over the same data for libraries that take one.

Partial downloads

-r requests a byte range, and the server answers 206 Partial Content:

using CurlDotNet;

var part = await Curl.ExecuteAsync("curl -r 0-99 https://httpbin.org/range/1000");
Console.WriteLine($"{part.StatusCode} {part.BinaryData.Length}");  // 206 100

Things to know