File Uploads in Flask Done Right

Uploading files looks simple until you think about path traversal, oversized payloads, unsafe filenames, MIME confusion, and where those files should actually live. This guide walks through a practical Flask setup that is small, secure, and production-friendly.

Validate early Check size, extension, and the file object before saving anything.
Name safely Never trust the original filename as your storage key.
Serve carefully Treat uploaded files as untrusted input even after they are stored.

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.
Rule of thumb: treat every uploaded file as untrusted until you validate it, rename it, and store it in a location you control.

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.

app.py Baseline config
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
Better default: use pathlib.Path for filesystem paths. The code is clearer, and path joins are less error-prone.

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.

app.py Single-file upload route
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.
Important: extension checks are not content checks. A file named photo.jpg can still contain something other than a JPEG.

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.

Optional image validation Pillow example
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.

  1. Store uploads outside your source code tree when possible.
  2. Rename files to unique IDs instead of trusting original names.
  3. Save metadata in your database, not just the path on disk.
  4. Keep the original filename only as display data.
Suggested metadata model Database fields
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.py Multiple-file upload pattern
@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
Design choice: for batch uploads, decide whether you want all-or-nothing behavior or partial success. Both are valid, but your API should make the rule obvious.

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.

app.py Safe download 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.

tests/test_uploads.py Pytest example
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

Using the original filename directly That can lead to collisions, ugly paths, and user-controlled file naming problems.
Skipping size limits Without a cap, one oversized upload can waste memory, time, and disk space.
Assuming extensions are enough A fake image with a real-looking extension is still a fake image.
Storing uploads in static blindly That makes every uploaded file immediately web-accessible, which is not always what you want.
Building paths with string concatenation Use Path joins or controlled helpers instead of raw string assembly.
Forgetting cleanup rules When a user deletes a record, decide whether the file should also be removed from storage.

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.
The big idea: a good upload system is boring in the best way. It is predictable, explicit, and hard for users to break accidentally.