When users need to download a PDF report, a CSV file exported from the system, or an uploaded image, the correct approach in Flask is send_file() with as_attachment=True and download_name. This function guesses the MIME type itself, sets the correct Content-Disposition header, and supports partial downloads — instead of you opening the file yourself and returning a hand-built Response. Use send_file() whenever your endpoint returns one specific file, whether that file lives on disk or was just created in memory.

The Problem

A familiar situation: after the user clicks “Export report”, the server creates the file and returns it. Many sample snippets do this: join the path with an f-string, open the file, and return a Response with a hand-written header. This carries three risks. First, a filename containing ../ can escape the allowed folder and read system files — a path traversal bug. Second, missing as_attachment lets the browser open the file directly instead of downloading it, or display sensitive content right in the tab. Third, a wrong hand-set MIME type makes the browser misinterpret the file, for example a CSV opened as HTML. All three are avoided if you validate the path first and let Flask handle the headers.

from flask import Flask, Response
app = Flask(__name__)
@app.get("/download/<path:filename>")
def download_bad(filename):
# NGUY HIỂM: filename = "../../secret.txt" sẽ đọc ra ngoài thư mục exports
with open(f"exports/{filename}", "rb") as f:
data = f.read()
return Response(data, headers={"Content-Disposition": f"attachment; filename={filename}"})

The Solution

The rule has two steps: block paths outside the allowed folder, then hand the file to send_file(). Use Path.resolve() and is_relative_to() to check — never trust a filename sent by the user. Once the file is inside the safe folder, send_file() handles the rest: guessing the MIME type, setting Content-Disposition with download_name, and supporting Range requests so downloads can resume when the network stutters. If the file only exists in memory, for example a CSV just built with io.StringIO, convert it to BytesIO and pass it straight to send_file() with a mimetype.

from pathlib import Path
from flask import Flask, abort, send_file
app = Flask(__name__)
BASE = Path(__file__).resolve().parent / "exports"
@app.get("/download/<path:filename>")
def download(filename):
path = (BASE / filename).resolve()
if not path.is_relative_to(BASE.resolve()) or not path.is_file():
abort(404)
return send_file(path, as_attachment=True, download_name=path.name)
import csv
import io
from flask import Flask, send_file
app = Flask(__name__)
@app.get("/export/users.csv")
def export_users():
text = io.StringIO()
writer = csv.writer(text)
writer.writerow(["id", "name", "email"])
writer.writerow([1, "Linh", "linh@example.com"])
buf = io.BytesIO(text.getvalue().encode("utf-8"))
return send_file(buf, mimetype="text/csv",
as_attachment=True, download_name="users.csv")

Apply It to Your Project

In a real project, put every downloadable file in its own folder such as exports/ or uploads/, and never let a client-supplied path decide which file is served. For on-demand exports, build the CSV in memory and return it with send_file() to avoid temporary file clutter. For user-uploaded files, combine an ownership check before calling send_file() — a path check only blocks traversal, it does not replace authorization.

Related Posts

FAQ

How is send_file() different from send_from_directory()?

send_from_directory() bundles the folder check and the file send into one call, which is convenient when files always live in one static folder. send_file() is more flexible when the file is in a dynamic location or only exists in memory.

When should you use as_attachment=False?

Set it to False when you want the browser to display the file directly, for example viewing a PDF or an image in a tab. Set it to True when you want to force the browser to download the file to the user’s machine.

Can a large file fill up RAM?

No — if you pass a file path on disk, Flask and Werkzeug stream the file in chunks instead of reading it all into RAM. Only a BytesIO file lives entirely in memory, so do not use that approach for large files.

Is download_name really necessary?

Yes — when the filename on disk differs from the name you want to show, or the name contains special characters, Flask encodes it correctly in the header.

💬 Have you tried this in your project? If you hit an error or know a cleaner way, leave a comment below — I read them all and will add the best ideas to this post.

References