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.MapPost("/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.MapPost("/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.
Size limits
Section titled “Size limits”Every request body is held to the server-wide Limits.MaxRequestBodySize, 30 MB by default and set in
configuration. An upload endpoint doesn’t need the whole server raised:
give that route its own limit and keep the default small everywhere else.
builder.Options.Limits.MaxRequestBodySize = 1024 * 1024; // 1 MB for everything...
app.MapPost("/images", Upload).WithRequestSizeLimit(500 * 1024 * 1024); // ...except thisapp.MapPut("/backups/{name}", Restore).DisableRequestSizeLimit(); // no limit at allOn a generated endpoint, use the attributes:
[Post("/images")][RequestSizeLimit(500 * 1024 * 1024)]public Task<IActionResult> Upload(HttpContext ctx) => ...;
[Put("/backups/{name}")][DisableRequestSizeLimit]public Task<IActionResult> Restore(string name, HttpContext ctx) => ...;A route’s limit can be higher or lower than the server’s. Routing applies it before authorization or the
handler runs, and the body is checked as it is read. A Content-Length over the limit gets a 413 at the
first read, before any of the body is accepted, and a chunked or HTTP/2 body without a length gets a 413
as soon as it goes over. Middleware that runs before routing can also set ctx.Request.MaxBodySize
itself. Once the body has started to be read, the limit can’t change: ctx.Request.IsMaxBodySizeReadOnly
turns true, and setting it throws.
A client that sends Expect: 100-continue only gets the 100 Continue when the handler starts reading
the body. A request that is turned away first (unauthorized, unrouted, or with a declared length over
the limit) never has its body sent.
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.MapGet("/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.


