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
- Use
-oorDownloadAsyncfor anything large - it writes straight to disk instead of holding the body in memory. - Check
IsBinarybefore treating a response as text -Bodyis for text,BinaryDatais for everything else. - Verify checksums when the publisher provides them.
- Use
-C -to resume - it appends to the existing file and fails with exit 33 if the server ignores the range. - Delete temporary files in a
finallyblock. - If you set
-mon big transfers, make it generous - it bounds the whole download, not just the wait for headers. - Quote
-Fvalues that contain spaces and use@only for file paths. - Catch
CurlReadErrorExceptionto give a clear message when an upload path is wrong.
Summary
curl -o file urlandCurl.DownloadAsync(url, path)save a download to disk and leaveresult.Bodyempty;-Okeeps the remote name.result.BinaryData,result.IsBinaryandresult.SaveToFile(path)handle downloads kept in memory.-F 'field=@path'uploads files as a form;-T filePUTs the raw bytes; the builder offersWithFileandWithMultipartForm.-C -resumes a download;-rrequests explicit byte ranges.
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 |