Why file uploads go wrong so often
The happy-path demo is tiny: grab request.files['file'], call save(), and move on. The trouble is that uploaded files are user input, and user input is where the weird edge cases live.
- Someone uploads a file named ../../app.py.
- A 2 GB video hits an endpoint that was only meant for profile images.
- The filename claims .jpg, but the content is not an image at all.
- Two users upload resume.pdf and the second overwrites the first.
- Files end up inside your project tree and get mixed with application code.
A solid baseline Flask setup
Start with a small configuration that sets an upload directory, an allowed extension list, and a maximum payload size. Flask itself gives you the essentials, and Werkzeug provides secure_filename() to strip dangerous characters from user-supplied names.
from pathlib import Path
from flask import Flask
BASE_DIR = Path(__file__).resolve().parent
UPLOAD_FOLDER = BASE_DIR / "uploads"
UPLOAD_FOLDER.mkdir(exist_ok=True)
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "pdf"}
MAX_CONTENT_LENGTH = 8 * 1024 * 1024 # 8 MB
app = Flask(__name__)
app.config["UPLOAD_FOLDER"] = str(UPLOAD_FOLDER)
app.config["MAX_CONTENT_LENGTH"] = MAX_CONTENT_LENGTH
The upload route: simple, explicit, and safe
A good upload route does four things in order: ensure the file exists in the request, ensure it has a usable name, verify it passes your rules, and save it under a new filename.
import os
import uuid
from pathlib import Path
from werkzeug.utils import secure_filename
from flask import Flask, request, jsonify
BASE_DIR = Path(__file__).resolve().parent
UPLOAD_FOLDER = BASE_DIR / "uploads"
UPLOAD_FOLDER.mkdir(exist_ok=True)
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "pdf"}
app = Flask(__name__)
app.config["UPLOAD_FOLDER"] = str(UPLOAD_FOLDER)
app.config["MAX_CONTENT_LENGTH"] = 8 * 1024 * 1024 # 8 MB
def allowed_file(filename: str) -> bool:
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
@app.post("/upload")
def upload_file():
if "file" not in request.files:
return jsonify({"error": "No file part in request"}), 400
file = request.files["file"]
if file.filename == "":
return jsonify({"error": "No file selected"}), 400
if not allowed_file(file.filename):
return jsonify({"error": "File type not allowed"}), 400
original_name = secure_filename(file.filename)
ext = original_name.rsplit(".", 1)[1].lower()
stored_name = f"{uuid.uuid4().hex}.{ext}"
save_path = Path(app.config["UPLOAD_FOLDER"]) / stored_name
file.save(save_path)
return jsonify({
"message": "Upload successful",
"original_name": original_name,
"stored_name": stored_name,
}), 201
Notice what the route does not do: it does not trust the raw filename, and it does not save the file under a user-controlled path. That alone avoids a surprising amount of pain.
Validation that actually matters
Extension checks are useful, but they are only the first line of defense. A production-ready upload flow usually validates several properties depending on the kind of file you expect.
| Check | Why it exists | Typical example |
|---|---|---|
| Extension whitelist | Blocks obviously unsupported types early. | Only allow png, jpg, and pdf. |
| Payload size | Prevents giant uploads from exhausting memory or disk. | Set MAX_CONTENT_LENGTH to 8 MB. |
| MIME or content inspection | Catches files pretending to be another format. | Check that an image is really an image. |
| Business rules | Keeps uploads relevant to the feature. | Avatar uploads must be square and under 1 MB. |
For images, it is often smart to open the file with Pillow before saving or before marking it as trusted. For PDFs or office documents, you may want malware scanning or asynchronous processing in a background job.
from PIL import Image
def is_valid_image(file_storage) -> bool:
try:
file_storage.stream.seek(0)
img = Image.open(file_storage.stream)
img.verify()
file_storage.stream.seek(0)
return True
except Exception:
file_storage.stream.seek(0)
return False
Storage strategy: where files should live
For small apps, saving to a dedicated folder on disk is perfectly reasonable. For larger apps, object storage like S3-compatible buckets becomes more attractive. Either way, the design rules are similar.
- Store uploads outside your source code tree when possible.
- Rename files to unique IDs instead of trusting original names.
- Save metadata in your database, not just the path on disk.
- Keep the original filename only as display data.
id
original_name
stored_name
content_type
size_bytes
uploaded_by_user_id
created_at
That separation makes deletion, audit logging, and migration to cloud storage much easier later.
Multiple file uploads without chaos
Flask can handle multiple uploads from a single form by using request.files.getlist('files'). The main idea stays the same: validate each file independently and report failures clearly.
@app.post("/uploads")
def upload_many():
files = request.files.getlist("files")
if not files:
return jsonify({"error": "No files uploaded"}), 400
saved = []
errors = []
for file in files:
if not file or file.filename == "":
errors.append("Encountered an empty file input")
continue
if not allowed_file(file.filename):
errors.append(f"{file.filename}: type not allowed")
continue
original_name = secure_filename(file.filename)
ext = original_name.rsplit(".", 1)[1].lower()
stored_name = f"{uuid.uuid4().hex}.{ext}"
save_path = Path(app.config["UPLOAD_FOLDER"]) / stored_name
file.save(save_path)
saved.append({
"original_name": original_name,
"stored_name": stored_name,
})
return jsonify({"saved": saved, "errors": errors}), 207 if errors else 201
Serving uploaded files safely
Saving is only half the story. You also need to think about how those files are returned later. Avoid building raw file paths from user input. Instead, look up a stored filename from the database, then serve it through a controlled route.
from flask import send_from_directory, abort
@app.get("/files/<stored_name>")
def get_file(stored_name):
safe_name = secure_filename(stored_name)
if safe_name != stored_name:
abort(400)
return send_from_directory(app.config["UPLOAD_FOLDER"], stored_name, as_attachment=True)
In many apps, you should not expose files by raw filename at all. A database ID or signed URL is usually cleaner.
Testing the flow so it stays correct
Upload code is exactly the kind of code that quietly regresses. A tiny test suite protects you from that.
import io
def test_upload_image(client):
data = {
"file": (io.BytesIO(b"fake image bytes"), "avatar.png")
}
response = client.post("/upload", data=data, content_type="multipart/form-data")
assert response.status_code in (201, 400)
def test_reject_bad_extension(client):
data = {
"file": (io.BytesIO(b"some bytes"), "script.exe")
}
response = client.post("/upload", data=data, content_type="multipart/form-data")
assert response.status_code == 400
Real tests would use a temporary upload directory and assert that files are saved with generated names, not original user filenames.
Common mistakes beginners make
Final checklist
If your Flask upload system does these things, you are already ahead of most demo-level implementations.
- Set MAX_CONTENT_LENGTH.
- Whitelist allowed extensions.
- Use secure_filename() for display-safe handling.
- Store files under generated names, not user names.
- Validate content for sensitive file types.
- Store metadata separately from the file itself.
- Serve files through controlled routes or signed URLs.
- Test both the success path and the rejection path.