Whenever your ASP.NET Core application hands a browser something to download — a CSV export, a PDF invoice, an image, a generated report — the action returns a FileResult. This tutorial covers the three overload families (bytes, stream, file on disk), the headers they produce (verified with curl), content types, inline display vs. download, and range support for large files.

The three ways to return a file
All three come from ControllerBase.File(...), so they work in MVC controllers and API controllers alike.
1. From a byte array — FileContentResult
For content you build in memory:
public FileResult DownloadBytes()
{
var bytes = Encoding.UTF8.GetBytes("ProductId,Name\n1,Galaxy A15\n");
return File(bytes, "text/csv", "products.csv");
}
Verified response:
200 OK
Content-Type: text/csv
Content-Disposition: attachment; filename=products.csv
The third argument is what turns the response into a download — it sets Content-Disposition: attachment with the filename the user's browser will save. Omit it and the browser tries to display the content inline instead.
2. From a stream — FileStreamResult
For content that's already a stream — a MemoryStream you generated into, a cloud-storage download, anything you don't want fully buffered as byte[]:
public FileResult DownloadStream()
{
var stream = new MemoryStream(Encoding.UTF8.GetBytes("streamed content"));
return File(stream, "application/octet-stream", "data.bin");
}
The framework disposes the stream for you after writing the response. This is the overload behind real-world exports — the SQL-to-Excel tutorial saves a ClosedXML workbook into a MemoryStream and returns exactly this.
3. From disk — PhysicalFileResult / VirtualFileResult
// absolute path on the server
public IActionResult Download() =>
PhysicalFile("/app/storage/report.pdf", "application/pdf", "report.pdf");
// path relative to wwwroot
public IActionResult Logo() =>
File("~/images/logo.png", "image/png");
PhysicalFile takes an absolute path; the File(virtualPath, …) string overload resolves inside wwwroot. Never build these paths from user input without validation — File("~/" + fileName, …) with fileName = "../appsettings.json" is a path-traversal hole. Map user-supplied identifiers to server-side paths through a lookup, not string concatenation.
Content types
The second argument is the MIME type the browser receives. Common ones:
| File | Content type |
|---|---|
application/pdf |
|
| Excel (.xlsx) | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet |
| CSV | text/csv |
| PNG / JPEG | image/png, image/jpeg |
| ZIP | application/zip |
| Anything binary/unknown | application/octet-stream |
Don't guess from the extension by hand — FileExtensionContentTypeProvider maps extensions to MIME types if you serve arbitrary stored files.
Inline vs. attachment
File(bytes, "application/pdf")— no filename → rendered inline (the PDF opens in the browser tab).File(bytes, "application/pdf", "invoice.pdf")— filename → downloaded asinvoice.pdf.
That's the whole difference; pick per use case.
Large files: enable range processing
For big downloads (videos, backups), let clients resume and seek:
return PhysicalFile(path, "video/mp4", enableRangeProcessing: true);
The response then honors Range: headers with 206 Partial Content — required for media scrubbing and download managers.
Resolving content types for arbitrary files
Serving stored files whose types vary? Map extension → MIME type with the built-in provider instead of a hand-rolled switch:
private static readonly FileExtensionContentTypeProvider ContentTypes = new();
public IActionResult Stored(string name)
{
var path = ResolveSafely(name); // your lookup, never raw user input
if (!ContentTypes.TryGetContentType(path, out var contentType))
contentType = "application/octet-stream"; // safe default: download, don't render
return PhysicalFile(path, contentType);
}
application/octet-stream as the fallback matters — serving an unknown file as text/html would let an uploaded HTML file execute scripts in your origin.
Relaying a file from another service
When the file lives behind another HTTP API (cloud storage, an internal service), stream it through without buffering the whole thing in memory:
public async Task<IActionResult> Relay(string id, CancellationToken ct)
{
var upstream = await httpClient.GetAsync(
$"https://files.internal/{id}",
HttpCompletionOption.ResponseHeadersRead, ct); // headers now, body as it streams
if (!upstream.IsSuccessStatusCode) return NotFound();
var stream = await upstream.Content.ReadAsStreamAsync(ct);
return File(stream, upstream.Content.Headers.ContentType?.ToString()
?? "application/octet-stream");
}
ResponseHeadersRead is the key — without it, GetAsync buffers the entire body before your code runs, which for a 2 GB file is a memory incident.
Testing actions that return files
FileResults are plain objects — assert on them directly:
[Fact]
public void Export_returns_csv_attachment()
{
var result = controller.DownloadBytes();
var file = Assert.IsType<FileContentResult>(result);
Assert.Equal("text/csv", file.ContentType);
Assert.Equal("products.csv", file.FileDownloadName);
Assert.StartsWith("ProductId,Name", Encoding.UTF8.GetString(file.FileContents));
}
No server needed — the result type, content type, filename, and bytes are all inspectable.
Serving user uploads safely
The pattern from this platform's own image storage: keep uploads outside wwwroot, address them by an unguessable path, and stream them through an action:
[AllowAnonymous]
[HttpGet("/blog-media/{**path}")]
public async Task<IActionResult> Media(string path, CancellationToken ct)
{
var stream = await storage.OpenReadAsync("blog-images", path, ct);
return stream is null ? NotFound() : File(stream, ContentTypeFor(path));
}
You get access control, logging, and content-type control that static files can't give you — at the cost of a little throughput. For fully public, high-traffic assets, static files (or a CDN) win; for anything gated, FileResult is the tool.
Comments (0)
No comments yet — be the first to share your thoughts.