📥 File Operations · 7 min read
Why Your CSV Download Opens in the Browser Instead of Saving
Your server described the file correctly but never told the browser what to do with it. RFC 6266 says a response is saved only when the disposition type is attachment; without it the browser applies default processing for the media type, and for text/csv that means showing the text in a tab. One response header fixes it: Content-Disposition: attachment, with a filename. Everything else in this article is about the cases where that header is already there and the file still arrives wrong.
The symptom has three common shapes, and they are not the same bug. A new tab full of comma-separated text means no disposition header. A file that saves as report.csv.txt means the type or the filename was wrong. A file that saves correctly but opens with one column, or with café instead of café, is a delimiter or encoding problem further down the chain.
What the browser reads before it decides
Three headers decide the outcome, and only the middle one is usually missing.
Content-Typetells the client what the bytes are. RFC 4180 section 3 registers the type astext/csv, withcharsetandheaderas its only optional parameters. MDN's list of common MIME types gives the same mapping:.csvistext/csv.Content-Dispositiontells the client what to do. RFC 6266 section 4.2 is explicit: if the disposition type isattachment, the recipient should "prompt the user to save the response locally, rather than process it normally (as per its media type)". If it isinline, that "implies default processing". No header at all is the same as inline.Content-Lengthlets the browser show a progress bar and a file size. It does not change the behaviour, but a chunked response with no length is where "the download says 0 KB and never finishes" comes from.
So a CSV that renders in a tab is not broken, and the browser is not ignoring you. The response said "this is text" and nothing more.
The fix, per stack
Every framework can set two headers on the response. In a Next.js route handler, return a Response with both:
// app/api/export/route.ts
export async function GET() {
const csv = "id,name,amount\r\n1,ada,10\r\n";
return new Response(csv, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": 'attachment; filename="report.csv"',
"Cache-Control": "no-store",
},
});
}Express has a method for exactly this, and it sets both headers at once: res.attachment("report.csv") then res.send(csv). In Flask, send_file(path, mimetype="text/csv", as_attachment=True, download_name="report.csv") does the same. In PHP, header('Content-Disposition: attachment; filename="report.csv"') before the output, with header('Content-Type: text/csv; charset=utf-8') beside it.
Two details worth copying from that snippet. Send CRLF line endings, as RFC 4180 section 2 requires, because a file written with bare newlines is what makes Excel show every record on one line. And write the header row yourself rather than letting a library drop it, since RFC 4180 leaves the presence of the header line to the producer.
We measured the four header combinations
To check that the disposition header is really the switch, we ran a local HTTP server with four endpoints returning identical bytes, id,name,amount plus two records, and read the responses back with curl on 2026-10-23:
| Endpoint | Headers sent | What the client is told to do |
|---|---|---|
/plain | Content-Type: text/csv | Nothing. Default processing, so the browser renders it. |
/attach | text/csv + Content-Disposition: attachment; filename="report.csv" | Save locally, under that filename. |
/inline | text/csv + Content-Disposition: inline | Default processing, same as no header. |
/octet | application/octet-stream + attachment | Save, but with no idea what the file is. |
The fourth row is the shortcut people reach for, and it costs them: application/octet-streammakes the browser download the file, but it also removes the hint that this is a CSV, so the operating system has nothing to associate it with and the file may arrive with a generic icon and a "how do you want to open this" dialog. Keep text/csv and add the disposition header instead.
Filenames: why the name comes back wrong
RFC 6266 section 4.3 defines two parameters. filename is the plain one, and filename* takes an RFC 5987 encoded value so that names outside ISO-8859-1 survive: filename*=UTF-8''%E9%94%80%E9%87%8F.csv for a Chinese filename. A bare non-ASCII filename is where names turn into percent signs or underscores.
Quote the filename. An unquoted value containing a space or a comma is legal-looking and parses wrong, which is why filename="Q3 report (final).csv" is the safe form. If both forms are present, browsers prefer filename*, and servers that support old clients send both.
When the server is right and the download still misbehaves
Four client-side causes account for most of the remaining reports.
- A remembered always-open choice.Chrome's download bubble lets a site set "always open files of this type", and that setting outranks the header for that site. It lives under Settings, Privacy and security, Site settings, Additional content settings, Automatic downloads. Clearing it restores the save prompt.
- A double extension.
report.csv.txthappens in two ways: a server that sendstext/plainand a browser that trusts the type over the name, or a text editor saving as plain text while file extensions are hidden, so the name looks correct in the dialog. Turning on file name extensions in the file manager is the one-second check. - Excel registered as the CSV handler.Clicking the link opens a spreadsheet instead of saving a file, because default processing means "hand it to the associated app". This is expected behaviour with no disposition header, and the user often never sees where the file went.
- A proxy that rewrites headers. Some CDNs and corporate proxies replace
Content-Typeon text-ish responses, and a few stripContent-Dispositionon the way out. Compare what your app returns withcurl -sI https://your.site/api/exportagainst the browser's network tab: if the headers differ, the header is being lost in transit, not in your code.
The bytes are right but the file reads wrong
Once the file saves, the next complaint is usually inside it: everything in one column, or accented letters mangled. Both are separate problems with their own checks. A one-column file means the delimiter in the file is not the one the reader expects, which the guide to a CSV opening in one column covers, and mangled letters are an encoding mismatch, covered in CSV file encoding. If you are about to send the file to someone else, the eight reasons a CSV upload is rejected lists what their importer will check before it accepts your export.
Frequently Asked Questions
Why does my CSV open in a browser tab instead of downloading?
Because the response header says what the content is, text/csv, but not what to do with it. RFC 6266 section 4.2 says a response is saved locally only when the disposition type is attachment; without that header the browser applies default processing for the media type, and for text/csv that means displaying the text. Add Content-Disposition: attachment to the response and the same bytes download.
Which header forces a CSV file to download?
Content-Disposition: attachment. Add filename or filename* so the saved file gets a proper name: Content-Disposition: attachment; filename="report.csv". Content-Type: text/csv should still be sent, because it tells the client what the file is, but it is the disposition header that turns a view into a download.
Why is my downloaded file called report.csv.txt?
Something added a second extension. The two usual causes are a browser that trusts the server's Content-Type over the filename, so a file served as text/plain gets .txt appended, and a text editor saving in plain text mode with file extensions hidden, which produces report.csv.txt without the name looking any different in the save dialog. Rename it back to a single .csv extension and check the Content-Type your server sends.
Can I make the file download without changing the server?
Sometimes. In Chrome, a site can remember an always-open choice from the download bubble, and clearing it in Settings, Privacy and security, Site settings, Additional content settings, Automatic downloads restores the prompt. That works for your own browser only. If the file must download for every visitor, the header has to change on the server, because the browser has nothing else to act on.
Why does clicking Export CSV open the data in Excel instead of saving it?
Excel is registered as the handler for text/csv on that machine, so default processing means handing the response to Excel. The server is behaving correctly and the file is not being saved anywhere. Send Content-Disposition: attachment and the browser saves the bytes first, leaving the user to open the file whenever they want.
Do accented characters get lost in a CSV download?
Two different causes. If the response declares no charset, the browser guesses, and a Windows-1252 byte stream can be read as UTF-8. If the client is Excel, it needs a UTF-8 byte order mark to recognise UTF-8 at all, so export with utf-8-sig rather than plain utf-8. RFC 4180 section 3 registers charset as an optional parameter of text/csv, and the matching header is Content-Type: text/csv; charset=utf-8.
Sources
- RFC 6266, Use of the Content-Disposition Header Field in the Hypertext Transfer Protocol, section 4.2 (attachment versus inline) and section 4.3 (filename, filename*, RFC 5987 encoding). Retrieved 2026-10-23.
- RFC 4180, Common Format and MIME Type for CSV Files, section 2 (CRLF record separator) and section 3 (registration of
text/csvwith optionalcharsetandheaderparameters). Retrieved 2026-10-23. - MDN Web Docs, Content-Disposition header reference, and Common MIME types (
.csvtotext/csv). Retrieved 2026-10-23. - Local header probe, 2026-10-23: Python 3.13.5
http.serveron 127.0.0.1:8791, four endpoints, headers read withcurl -D -. Output reproduced in the table above.
Check the File You Just Downloaded
Drop the CSV into the analyzer and it reports the delimiter, the encoding, the column types and the row count it actually parsed, which is the fastest way to see whether the download or the reader is at fault.
Related reading
File Operations — other guides that pair well with this one.
Browse all guides in the NoCodeCSV blog.