How to Use Flask send_file to Serve User-Uploaded PDFs: Avoiding Common Pitfalls
Always use send_from_directory instead of raw user paths, explicitly set mimetype="application/pdf", and ensure file pointers are seeked to the start to prevent empty responses or path traversal vulnerabilities.
Serving user-uploaded PDFs with Flask's send_file utility requires careful attention to security headers and file handling. While the send_file function in src/flask/helpers.py provides a convenient wrapper around Werkzeug's file-sending capabilities, improper use of path arguments or MIME types can result in 404 errors, empty responses, or path traversal vulnerabilities. This guide examines the most common pitfalls when using Flask send file to deliver PDFs and provides code-backed solutions straight from the Flask source.
1. Path Traversal Vulnerabilities and Unsafe User Input
The Risk of Raw User-Supplied Paths
send_file assumes the supplied path is trusted. Supplying a path that came from a request parameter can expose the server to path-traversal attacks (e.g., ../../../etc/passwd) and will often result in a "file not found" error because the file lives outside the expected directory. The Flask source explicitly warns about this behavior:
- Source: Never pass file paths provided by a user… Use
send_from_directory–src/flask/helpers.py
Solution: Use send_from_directory with Safe Join
Store uploads in a fixed upload folder (e.g., app.config["UPLOAD_FOLDER"]) and serve them with send_from_directory, which sanitizes the path using werkzeug.security.safe_join before resolving the file.
from flask import Flask, send_from_directory, abort
import os
app = Flask(__name__)
app.config["UPLOAD_FOLDER"] = os.path.join(app.instance_path, "uploads")
app.config["USE_X_SENDFILE"] = True # optional, for large files
@app.route("/download/<path:filename>")
def download_pdf(filename):
# `send_from_directory` sanitises the path and prevents traversal.
try:
return send_from_directory(
app.config["UPLOAD_FOLDER"],
filename,
mimetype="application/pdf",
as_attachment=True,
download_name=filename, # ensure a safe name
)
except Exception as e:
app.logger.error(f"Failed to serve {filename}: {e}")
abort(404)
2. MIME Type Detection Failures
Explicitly Setting application/pdf
When the mimetype argument is omitted, Flask attempts to guess the type from the filename. If the filename lacks a .pdf extension or the system guess fails, the response defaults to application/octet-stream, causing browsers to mishandle the PDF or prompt for download incorrectly.
- Source: If not provided, it will try to detect it from the file name –
src/flask/helpers.py
Avoidance: Always explicitly set mimetype="application/pdf" when serving PDFs, or ensure uploaded files retain their original extensions.
Handling Content-Disposition for Downloads vs Inline
By default, as_attachment=False sends Content-Disposition: inline, which instructs browsers to render the PDF within the page. Some browsers may block this due to Content Security Policy (CSP) restrictions or missing plugins, leading users to believe the file was not delivered.
- Source:
as_attachmentindicates to a browser that it should offer to save the file –src/flask/helpers.py
Avoidance: For downloadable PDFs, pass as_attachment=True and optionally provide a safe download_name.
return send_file(
path,
mimetype="application/pdf",
as_attachment=True,
download_name="report.pdf",
)
3. File Object State and Binary Mode
Seeking to the Start of File-Like Objects
When passing an opened file object (e.g., a BytesIO buffer built on the fly), Flask expects the pointer to be at the start of the data. If the pointer remains at the end after writing, the response body will be empty.
- Source: Make sure the file pointer is seeked to the start of the data –
src/flask/helpers.py
Avoidance: Always call seek(0) on file-like objects before passing them to send_file.
from flask import send_file
from io import BytesIO
def generate_report():
# Build PDF in memory...
pdf_bytes = b"%PDF-1.4 ..."
pdf_stream = BytesIO(pdf_bytes)
pdf_stream.seek(0) # <-- important!
return send_file(
pdf_stream,
mimetype="application/pdf",
as_attachment=True,
download_name="report.pdf",
)
Ensuring Binary Read Mode
When opening physical files for send_file, always use binary mode ("rb"). Text mode will corrupt binary PDF data and may raise encoding errors.
4. Performance and Server Configuration
Enabling X-Sendfile for Large PDFs
If the WSGI server supports X-Sendfile (Apache) or X-Accel-Redirect (Nginx) and app.config["USE_X_SENDFILE"] = True, Flask instructs the server to transfer the file directly from disk without loading it into Python memory. Forgetting to enable this flag on a compatible server results in Flask reading large PDFs into memory, causing high CPU and memory usage.
- Source: If the HTTP server supports “X‑Sendfile”, configuring Flask with
USE_X_SENDFILE = Truewill tell the server to send the given path –src/flask/helpers.py
Avoidance: Set app.config["USE_X_SENDFILE"] = True and configure the web server accordingly.
Cache Control and Conditional Responses
send_file enables conditional responses (ETag / If-Modified-Since) when a real file path is given. Disabling conditional or misconfiguring cache headers can cause browsers to repeatedly download large PDFs or receive unexpected 304 responses.
- Source:
conditionalenables conditional and range responses –src/flask/helpers.py - Source:
max_agecontrolsCache‑Control–src/flask/helpers.py
Avoidance: Keep conditional=True (default) and set max_age appropriately, or rely on the app-wide default via app.get_send_file_max_age.
@app.route("/cached/<path:name>")
def cached_pdf(name):
return send_from_directory(
app.config["UPLOAD_FOLDER"],
name,
mimetype="application/pdf",
as_attachment=False,
max_age=86400, # 1 day client-side caching
)
5. Relative Paths and Working Directory Issues
If you pass a relative path to send_file, Flask resolves it against the process’s current working directory (CWD), which may differ between development, testing, and production environments (e.g., when using a WSGI server like Gunicorn). This frequently causes "file not found" errors for uploaded PDFs.
- Source: The path is relative to the current working directory if a relative path is given –
src/flask/helpers.py
Avoidance: Use absolute paths constructed with os.path.join(app.root_path, ...) or, preferably, use send_from_directory which prefixes the safe base directory automatically.
6. Filename Encoding and Special Characters
Older Flask versions encoded filenames in Latin-1, which broke Unicode PDF names. Since Flask 1.0.3, filenames are normalized to ASCII for broader compatibility, but non-ASCII characters in download_name can still cause encoding issues in certain browsers.
- Source: Filenames are encoded with ASCII instead of Latin-1 for broader compatibility –
src/flask/helpers.py
Avoidance: Clean or slugify filenames before passing them to download_name, or ensure your application handles UTF-8 filename encoding explicitly.
Summary
- Never trust user paths: Always use
send_from_directorywith a fixed upload folder to prevent path traversal attacks, assend_fileassumes trusted input (src/flask/helpers.py. - Set explicit MIME types: Pass
mimetype="application/pdf"to avoidapplication/octet-streamdefaults that confuse browsers (src/flask/helpers.py. - Control disposition: Use
as_attachment=Trueand a safedownload_nameto force downloads rather than inline rendering that may be blocked by CSP (src/flask/helpers.py. - Manage file pointers: When using file-like objects (e.g.,
BytesIO), alwaysseek(0)before callingsend_fileto avoid empty responses (src/flask/helpers.py. - Optimize delivery: Enable
USE_X_SENDFILEfor large PDFs on compatible servers (Nginx/Apache) to offload file transfer from Python memory (src/flask/helpers.py. - Handle caching: Leverage conditional responses (
conditional=True) and set appropriatemax_ageto prevent redundant downloads (src/flask/helpers.py, L50-L53).
Frequently Asked Questions
Why is my PDF showing as blank or empty when using Flask send_file?
If you are passing a file-like object (such as BytesIO) to send_file, the file pointer is likely positioned at the end of the buffer after writing. Flask reads from the current position, resulting in an empty response body. According to the source in src/flask/helpers.py, you must call seek(0) on the object before passing it to send_file.
How do I prevent path traversal attacks when serving user-uploaded files?
Never pass raw user-supplied filenames directly to send_file, as it assumes the path is trusted and resolves relative paths against the current working directory. Instead, use send_from_directory, which sanitizes the filename using werkzeug.security.safe_join against a fixed base directory. The Flask source explicitly warns against user paths in src/flask/helpers.py.
Should I use send_file or send_from_directory for user uploads?
For user-uploaded content, always prefer send_from_directory. While send_file is suitable for static assets or dynamically generated file objects, send_from_directory adds essential security checks that prevent directory traversal. It also handles the conversion of relative paths to absolute paths relative to the specified directory, avoiding issues where the current working directory differs between development and production environments.
Why does my PDF download with the wrong filename or garbled characters?
Flask encodes filenames as ASCII for broader compatibility (since version 1.0.3), which can cause issues with non-ASCII characters in download_name. If the filename contains Unicode characters that cannot be encoded to ASCII, the browser may display garbled text or a generic name. Always sanitize or slugify user-provided filenames before passing them to download_name, or explicitly handle RFC 5987 encoding for UTF-8 filenames.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →