Flask file uploads with validation and private storage
To upload a file with Flask, submit a multipart form, validate the request, and save the uploaded stream. This walkthrough gives you a local browser form that accepts JPEG, PNG, and PDF files and stores them outside the public static directory. You will also check the saved bytes and see what happens when an upload is rejected.
Setting up your Flask environment
Use Linux with Bash, Python 3.14, virtual environment support, and libmagic installed. The python-magic installation instructions cover the system library; installing the Python package alone does not install it. The example uses Flask 3.1.3, Flask-WTF 1.3.0, and python-magic 0.4.27.
From a directory where you keep projects, run:
mkdir flask-upload-demo &&
cd flask-upload-demo &&
python3 -m venv .venv &&
.venv/bin/python -m pip install Flask==3.1.3 Flask-WTF==1.3.0 python-magic==0.4.27 &&
mkdir templates
The commands stop if the project already exists or any setup step fails. Continue only after they
succeed, with your terminal still in flask-upload-demo. Create the following two files there.
Building the file upload form
Save this as templates/upload.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Upload a file</title>
</head>
<body>
<h1>Upload a file</h1>
<p>Choose a JPEG, PNG, or PDF. The whole request must fit within 16 MiB.</p>
{% for message in get_flashed_messages() %}
<p role="status">{{ message }}</p>
{% endfor %}
{% if error %}<p role="alert">{{ error }}</p>{% endif %}
<form method="post" enctype="multipart/form-data" novalidate>
{{ form.hidden_tag() }}
{{ form.file.label }} {{ form.file }}
{% for errors in form.errors.values() %}
{% for message in errors %}
<p role="alert">{{ message }}</p>
{% endfor %}
{% endfor %}
<button type="submit">Upload</button>
</form>
</body>
</html>
The multipart encoding carries the file bytes. hidden_tag() renders the CSRF token that
Flask-WTF checks against the browser session. novalidate lets you try the server’s missing-file
response instead of having the browser stop submission first. The form class is in app.py below.
Implementing file upload handling
Save this complete application as app.py:
import os
import tempfile
from pathlib import Path
import magic
from flask import Flask, flash, redirect, render_template, request, url_for
from flask_wtf import FlaskForm
from flask_wtf.file import FileAllowed, FileField, FileRequired
from werkzeug.exceptions import RequestEntityTooLarge
app = Flask(__name__)
app.config['SECRET_KEY'] = os.environ['FLASK_SECRET_KEY']
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024
upload_directory = Path(app.instance_path) / 'uploads'
upload_directory.mkdir(mode=0o700, parents=True, exist_ok=True)
ALLOWED_TYPES = {
'image/jpeg': ('jpg', 'jpeg'),
'image/png': ('png',),
'application/pdf': ('pdf',),
}
class UploadForm(FlaskForm):
file = FileField('File', validators=[
FileRequired('Choose a file.'),
FileAllowed(['jpg', 'jpeg', 'png', 'pdf'], 'Choose a JPEG, PNG, or PDF.'),
])
def save_upload(file):
extension = file.filename.rsplit('.', 1)[-1].lower()
detected_type = magic.from_buffer(file.read(2048), mime=True)
file.seek(0)
extensions = ALLOWED_TYPES.get(detected_type)
if extensions is None or extension not in extensions:
raise ValueError('The file contents do not match an allowed file type and extension.')
# The client filename never becomes a storage path.
target = tempfile.NamedTemporaryFile(
dir=upload_directory, prefix='upload-', suffix='.' + extensions[0], delete=False,
)
try:
with target:
file.save(target)
except OSError:
Path(target.name).unlink(missing_ok=True)
raise
return Path(target.name).name
@app.errorhandler(RequestEntityTooLarge)
def too_large(error):
# Do not parse the rejected request again while rendering its error page.
form = UploadForm(formdata=None)
return render_template(
'upload.html', form=form,
error='Upload request is too large. Choose a smaller file.',
), 413
@app.route('/', methods=['GET', 'POST'])
def upload():
form = UploadForm()
if request.method == 'GET':
return render_template('upload.html', form=form)
if not form.validate_on_submit():
return render_template('upload.html', form=form), 400
try:
filename = save_upload(form.file.data)
except ValueError:
return render_template(
'upload.html', form=form,
error='The file contents do not match an allowed file type and extension.',
), 400
except (OSError, magic.MagicException) as error:
app.logger.error('Upload failed (%s)', type(error).__name__)
return render_template(
'upload.html', form=form,
error='The file could not be saved. Please try again.',
), 500
flash(f'Saved as {filename}.')
return redirect(url_for('upload'), code=303)
Flask-WTF’s file validators check that a file was selected and its extension is allowed. The helper then uses libmagic to inspect the first 2,048 bytes, following python-magic’s guidance, and rewinds the stream before saving it. Without that rewind, the stored file would lose the bytes already read. The browser’s claimed MIME type is not used for this decision.
These checks reject obvious mismatches, such as a text file renamed to .png. They do not fully
decode the file, detect all corruption, or prove that its contents are harmless. Keep uploads
private until any scanning or format-specific processing your application needs has succeeded.
NamedTemporaryFile creates a new name on every accepted upload, with file permissions restricted
to the creating user on Linux. Even two uploads called photo.png get separate files. Despite the
function’s name, delete=False keeps successful uploads in instance/uploads until you remove
them. A caught write or close error triggers cleanup of that attempt’s partial file; a killed
process or a filesystem that refuses deletion can still leave a file behind.
Run the form and verify an upload
In the same project directory, generate a local session secret and start Flask:
FLASK_SECRET_KEY="$(.venv/bin/python -c 'import secrets; print(secrets.token_hex(32))')" &&
export FLASK_SECRET_KEY &&
.venv/bin/python -m flask --app app run --host 127.0.0.1 --port 5000
Open http://127.0.0.1:5000/, select a small JPEG, PNG, or PDF with
File, and press Upload.
The returned page shows Saved as followed by the generated
filename. A successful POST redirects to a GET, so refreshing that result page does not submit
the file again. Deliberately submitting the form again creates another copy.
In another terminal, enter the project directory and compare your original file with the saved one. Replace both example paths, using the generated name from the page:
cmp -- '/path/to/photo.png' 'instance/uploads/upload-example.png'
No output and exit status zero mean the bytes match. The saved file is outside Flask’s static
directory, and this app has no download route. Keep the instance directory out of any public web
server mapping.
Stop Flask with Ctrl+C. To restart, run the start command from the project directory again. It generates a new secret, so reload any open form before submitting it. If port 5000 is occupied, choose an unused port in the command and browser URL. This development server stays on loopback; the app has no login, storage quota, or automatic deletion policy.
Error handling and validation
Try these cases while watching the browser’s Network panel for the POST status:
| Input or condition | Expected response | Stored result |
|---|---|---|
| Submit without selecting a file | 400 and Choose a file. | No new file |
Select a .txt file | 400 and Choose a JPEG, PNG, or PDF. | No new file |
Rename plain text to .png, or submit an empty .png | 400 and a content/type mismatch message | No new file |
| Select a file of 16 MiB or more | 413 and Upload request is too large. Choose a smaller file. | No new file |
Remove the hidden csrf_token input in browser developer tools before submitting | 400 and The CSRF token is missing. | No new file |
| Upload when the storage directory is not writable | 500 and The file could not be saved. Please try again. | No new file if creation failed |
The request limit includes multipart fields and boundaries, so the largest accepted file is slightly smaller than 16 MiB. Flask also limits multipart field sizes and counts. Its upload documentation notes that some development-server uploads can end in a connection reset instead of displaying a 413 response.
For CSRF errors, reload the page and select the file again. Keep the secret stable when running multiple application processes: they must agree on session and token signatures. CSRF protection validates the form’s session token; it does not authenticate an uploader. Storage failures log only the exception type, leaving local paths and diagnostic details out of the page.
If you need a Flask file upload API
This example returns HTML and requires the browser’s session cookie and CSRF token. A bare multipart cURL request therefore fails form validation. For a separate API, decide who may upload, how clients authenticate, and which JSON responses they handle before reusing the validation and storage logic. A cookie-authenticated API still needs CSRF protection; changing the response format does not remove that requirement.
Handling large file uploads
If you later put the app behind Nginx, its
client_max_body_size
can reject the request before Flask sees it. Align the proxy and application limits, and handle
errors at both layers. Raising a proxy limit alone cannot override the 16 MiB Flask limit.
For resumable uploads, use a protocol such as tus with a compatible server and client. The form here sends one multipart request; splitting a file into browser chunks would require a different server implementation to track and assemble them.
Background processing is a separate step after receiving a file. A worker can process stored bytes, but it does not make this form resumable or bypass the request limit. This local example ends when the file is saved. If processing later becomes slow, Flask’s Celery guide explains the additional worker and broker setup; you will also need a policy for failed jobs and retained uploads.
Before making uploads public
Choose a retention policy for instance/uploads before accumulating real user data. Add
authentication, per-user authorization and quotas, and the content checks needed for your file
formats before accepting public traffic or exposing downloads. Those decisions belong to the app
that uses the uploaded files; the local form gives you a place to test them.
