Uploads & Downloads
Uploads
Section titled “Uploads”Streaming multipart
Section titled “Streaming multipart”This is the one to use for real uploads. Each part’s body is a stream that ends at the boundary, so a 2 GB file goes straight to disk and nothing large is ever held in memory.
app.OnPost("/upload", async ctx =>{ await foreach (var part in ctx.Request.ReadMultipartAsync(ctx.RequestAborted)) { if (!part.IsFile) continue;
var name = part.SafeFileName() ?? "upload.bin"; await part.SaveToAsync(Path.Combine(uploadDirectory, name), ctx.RequestAborted); }
return Results.NoContent();});A MultipartSection gives you Headers, ContentType, Name, FileName, IsFile, the raw Body
stream, and ReadAsStringAsync / CopyToAsync / SaveToAsync.
MultipartReader bounds itself: HeadersLengthLimit (16 KB) and PartCountLimit (256) per body.
Buffered forms
Section titled “Buffered forms”For a handful of small fields — a login form, a settings post — buffering is simpler:
app.OnPost("/settings", async ctx =>{ if (!ctx.Request.HasFormContentType()) return Results.BadRequest();
var form = await ctx.Request.ReadFormAsync();
var name = form.GetFirst("name"); var avatar = form.GetFile("avatar"); // FormFile: Content, Length, OpenReadStream, SaveToAsync
return Results.NoContent();});ReadFormAsync handles both application/x-www-form-urlencoded and multipart/form-data, and is
bounded on purpose: maxValueLength defaults to 64 KB and maxFileSize to 8 MB per file, and
exceeding either is a 413. Buffering an upload of unknown size is how a server runs out of memory.
Raw bodies
Section titled “Raw bodies”For a PUT-style upload with no multipart wrapper:
var written = await ctx.Request.SaveBodyToAsync(path, ctx.RequestAborted);Never buffers. ctx.Request.Body is the stream itself, and ctx.Request.BodyReader is the
PipeReader for zero-copy parsing.
Downloads
Section titled “Downloads”Results.File(path) and Results.Stream(...) cover the simple case. FileDownloadResult is what
they and the static file middleware are built on, and it is what you
want when the response should support resuming:
app.OnGet("/recordings/{id}", ctx => FileDownloadResult.FromFile(PathFor(id), downloadName: "recording.mp4"));| Factory | For |
|---|---|
FromFile(path, …) |
A file on disk — length, ETag and modification time filled in |
FromBytes(content, …) |
Bytes already in memory |
FromStream(stream, length, …) |
A seekable stream of known length; disposed after writing |
FromOpener(open, length, …) |
A factory opened only if the response is actually written |
FromOpener exists because a resolver that tries several candidates — the static file handler
looking for default documents — would otherwise open and discard a stream per miss, and a conditional
request ending in a 304 opens nothing at all.
Properties: ContentType, FileDownloadName, Inline, ETag, LastModified. Setting a download
name sends Content-Disposition: attachment unless Inline is set.
What you get for free
Section titled “What you get for free”- Byte ranges, including suffix ranges (
Range: bytes=-500), answered with 206 andContent-Range. - 416 for a range past the end, rather than a silently truncated body.
ETag/Last-Modifiedconditional GETs —If-None-MatchandIf-Modified-Sinceproduce a 304 with no body.If-Range, so a stale resume restarts the download instead of splicing two different files together.- A content-type table that falls back to
application/octet-streamrather than guessingtext/html.
ContentTypes.ForFileName(name) is that table, if you want the same answer somewhere else.


