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, Responseapp = 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 Pathfrom flask import Flask, abort, send_fileapp = 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 csvimport iofrom flask import Flask, send_fileapp = 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
- Flask: Upload Files Safely — Validate File Type and Limit Size in 5 Minutes
- Flask: Log the Right Way with app.logger — Replace print() Before Production
- Flask: Use the Application Factory (create_app) So Your App Is Easy to Test and Scale from Day One
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
- Flask — send_file: https://flask.palletsprojects.com/en/stable/api/#flask.send_file
- Werkzeug — send_file: https://werkzeug.palletsprojects.com/en/stable/utils/#werkzeug.utils.send_file
- Flask — Uploading Files (filename security): https://flask.palletsprojects.com/en/stable/patterns/fileuploads/
