Skip to the content.

Tutorial 10: curl download file in C# (curl -o) and upload with -F

This tutorial shows how to curl download file in C# (curl -o) and upload with -F using CurlDotNet. You will save responses to disk, keep the remote file name with -O, work with binary data in memory, resume partial downloads with -C - or -r, upload a raw file with -T, and upload files as multipart forms.

If you have not read Tutorial 5: Understanding Results yet, do that first; this page builds on CurlResult.

Downloading Files

Simple file download (-o)

In curl you write curl -o image.png https://httpbin.org/image/png. CurlDotNet runs exactly that string and records where the file went:

using CurlDotNet;

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

Console.WriteLine(result.StatusCode);                     // 200
Console.WriteLine($"Saved: {result.OutputFiles[0]}");     // Saved: image.png
Console.WriteLine(result.Body.Length);                    // 0: the body went to the file, as with curl

// Convenience helper with the same effect
await Curl.DownloadAsync("https://httpbin.org/image/png", "image.png");

As with curl, -o and -O send the body to the file and leave result.Body empty (curl prints nothing to stdout then). Read the file, or result.OutputFiles, to get at the content. A few related curl options work the same way:

Option Effect
-i -o file The response header block is written into the file before the body
-D file / -D - Response headers go to a separate file / to result.Body
--output-dir dir, --create-dirs Put the file in a directory, creating missing directories
--no-clobber, --skip-existing Never overwrite / skip the download when the file already exists
-J With -O, use the server’s Content-Disposition filename (never overwrites)
-R Set the file’s modification time from Last-Modified
--remove-on-error Delete the output file when the transfer fails
--max-filesize N Refuse bodies larger than N bytes (CurlFileSizeExceededException, exit 63)

A missing output directory (without --create-dirs) throws CurlWriteErrorException (exit 23), not a raw IOException.

The builder version uses WithOutput:

using CurlDotNet.Core;

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

Keep the remote file name (-O)

-O (capital O) names the local file after the last segment of the URL, just like curl:

// Same as: curl -O https://httpbin.org/robots.txt   -> writes ./robots.txt
var result = await Curl.ExecuteAsync("curl -O https://httpbin.org/robots.txt");

Console.WriteLine(string.Join(", ", result.OutputFiles));   // robots.txt

Download to memory

When you leave out -o, the response stays in the result. Text lands in Body; anything binary lands in BinaryData:

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

if (result.IsBinary)
{
    byte[] bytes = result.BinaryData;
    Console.WriteLine($"{bytes.Length} bytes of {result.GetHeader("Content-Type")}");

    result.SaveToFile("image.png");            // or: await result.SaveToFileAsync("image.png")
}

For a text file the same pattern works through Body:

var result = await Curl.ExecuteAsync("curl https://httpbin.org/robots.txt");

Console.WriteLine(result.Body);                // the file contents as text
await result.SaveToFileAsync("robots.txt");

Binary detection looks at the Content-Type header and, when that is missing or misleading, at the bytes themselves. If you need a stream instead of an array, call result.ToStream().

Uploading Files

Simple file upload (-F)

curl’s -F field=@path sends a multipart/form-data POST with the file in the named field. httpbin echoes back what it received, which makes it a handy test target:

// Same as: curl -F 'file=@report.pdf' https://httpbin.org/post
var result = await Curl.ExecuteAsync(
    "curl -F 'file=@report.pdf' https://httpbin.org/post");

Console.WriteLine(result.StatusCode);   // 200
Console.WriteLine(result.Body);         // JSON containing a "files" section

File plus form fields

Add one -F per field. Quote values that contain spaces:

var result = await Curl.ExecuteAsync(@"curl \
  -F 'document=@report.pdf' \
  -F 'description=Monthly report' \
  -F 'category=reports' \
  https://httpbin.org/post");

The builder has two ways to express the same request. WithFile adds one file; WithMultipartForm takes fields and files as dictionaries:

var fields = new Dictionary<string, string>
{
    ["description"] = "Monthly report",
    ["category"] = "reports"
};
var files = new Dictionary<string, string> { ["document"] = "report.pdf" };

var result = await CurlRequestBuilder
    .Post("https://httpbin.org/post")
    .WithMultipartForm(fields, files)      // or: .WithFile("document", "report.pdf")
    .ExecuteAsync();

Multiple files

Repeat -F for each file. Building the command from a list keeps it readable:

var paths = new[] { "a.jpg", "b.jpg", "c.jpg" };
var parts = string.Join(" ", paths.Select(p => $"-F 'photos=@{p}'"));

var result = await Curl.ExecuteAsync($"curl {parts} https://httpbin.org/post");
Console.WriteLine(result.IsSuccess ? "Uploaded" : $"HTTP {result.StatusCode}");

Upload a raw file (-T)

-T/--upload-file sends the file’s bytes as the request body of a PUT. If the URL ends in /, the file name is appended, as curl does:

// Same as: curl -T report.pdf https://httpbin.org/put
var result = await Curl.ExecuteAsync("curl -T report.pdf https://httpbin.org/put");

A missing -T or -F file throws CurlReadErrorException (exit 26) before anything is sent.

Partial and Resumed Downloads (-C, -r)

The curl way to resume an interrupted download is -C - (--continue-at -): CurlDotNet looks at the size of the existing output file, asks the server for the rest with a Range header, and appends what arrives:

// Same as: curl -C - -o big.bin https://example.com/big.bin
var result = await Curl.ExecuteAsync("curl -C - -o big.bin https://example.com/big.bin");

-C N resumes from a fixed byte offset. If the server ignores the range and sends the whole file again, the transfer fails with CurlRangeErrorException (exit 33), exactly like curl, so a complete file is never appended to a partial one. A 416 Range Not Satisfiable reply to -C - means the file is already complete and leaves it untouched. Resuming an upload (-C with -d or -T) throws CurlBadFunctionArgumentException (exit 43).

-r asks for an explicit byte range. The server answers 206 Partial Content when it honours the range:

// Same as: curl -r 0-1023 -o first-kb.bin https://httpbin.org/range/4096
var result = await Curl.ExecuteAsync(
    "curl -r 0-1023 -o first-kb.bin https://httpbin.org/range/4096");

Console.WriteLine(result.StatusCode);   // 206

You can also resume by hand: ask for everything from the bytes you already have and append:

const string path = "big.bin";
long have = File.Exists(path) ? new FileInfo(path).Length : 0;

var result = await Curl.ExecuteAsync($"curl -r {have}- https://httpbin.org/range/4096");

if (result.StatusCode == 206)
{
    using var fs = new FileStream(path, FileMode.Append);
    fs.Write(result.BinaryData, 0, result.BinaryData.Length);
}
else if (result.StatusCode == 200)
    result.SaveToFile(path);   // server ignored the range: start over

Verifying a Download

Hash the bytes before you keep them. SHA-256 is the usual choice today:

using System.Security.Cryptography;

var result = await Curl.ExecuteAsync("curl https://httpbin.org/image/png");
var hash = Convert.ToHexString(SHA256.HashData(result.BinaryData));

if (hash.Equals(expectedSha256, StringComparison.OrdinalIgnoreCase))
    result.SaveToFile("image.png");
else
    Console.WriteLine($"Checksum mismatch: {hash}");

Temporary Files

Download to a temp path, process it, and always delete it:

var tempFile = Path.GetTempFileName();
try
{
    await Curl.DownloadAsync("https://httpbin.org/robots.txt", tempFile);
    Console.WriteLine(File.ReadAllText(tempFile));
}
finally
{
    File.Delete(tempFile);
}

Error Handling for File Operations

A missing upload file is detected before anything is sent and throws CurlReadErrorException (curl exit code 26):

using CurlDotNet.Exceptions;

try
{
    await Curl.ExecuteAsync("curl -F 'file=@missing.pdf' https://httpbin.org/post");
}
catch (CurlReadErrorException ex)
{
    Console.WriteLine($"Upload file not found: {ex.Message}");
}

Like curl, CurlDotNet has no timeout unless you set one, so a large download is never cut off by a hidden default. When you do want a deadline, set it with -m in the command or with CurlSettings.WithTimeout (seconds); -m covers the whole transfer, body included:

var settings = new CurlSettings().WithTimeout(300);

var result = await Curl.ExecuteAsync(
    "curl -o big.bin https://httpbin.org/bytes/1048576", settings);

Console.WriteLine(result.IsSuccess ? "Downloaded" : $"HTTP {result.StatusCode}");

Remember that HTTP 4xx and 5xx responses do not throw; check result.IsSuccess or call result.EnsureSuccess(). Timeouts surface as CurlOperationTimeoutException (see Tutorial 12).

Best Practices

  1. Use -o or DownloadAsync for anything large - it writes straight to disk instead of holding the body in memory.
  2. Check IsBinary before treating a response as text - Body is for text, BinaryData is for everything else.
  3. Verify checksums when the publisher provides them.
  4. Use -C - to resume - it appends to the existing file and fails with exit 33 if the server ignores the range.
  5. Delete temporary files in a finally block.
  6. If you set -m on big transfers, make it generous - it bounds the whole download, not just the wait for headers.
  7. Quote -F values that contain spaces and use @ only for file paths.
  8. Catch CurlReadErrorException to give a clear message when an upload path is wrong.

Summary

See also the cookbook recipes Download a File and Upload a File.

What’s Next?

Learn about working with forms and data in the next tutorial.


Previous: Authentication Basics Next: Forms and Data