Handling File Uploads

Reviewed & published by Brayan K

A file upload lets a user send a file — an image, PDF, or document — from their browser to your Flask app through an HTML form.

Part of the free Flask course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

In this lesson you'll wire up the right form, read uploads from request.files, sanitize filenames with secure_filename, save files to disk, and lock things down by validating the file type and capping the upload size.

What You'll Learn in This Lesson

1 Reading an Uploaded File

File uploads travel in a special kind of POST request. For the bytes of the file to actually reach your server, the HTML form must declare enctype="multipart/form-data" and use method="POST". The form also needs an <input type="file" name="file"> — that name is the key you'll look up on the server.

On the Flask side, uploaded files appear in request.files, a dictionary-like object. Each value is a FileStorage object that exposes .filename (the original name the browser sent), .read() (the raw bytes), and .save(path) (write it to disk).

Always check that the file is present and that a file was actually chosen before you use it — an empty filename means the user submitted the form without picking anything. The example below validates both conditions, then reports the name and byte count.

# app.py — read an uploaded file from request.files
import io
from flask import Flask, request

app = Flask(__name__)

@app.route("/upload", methods=["POST"])
def upload():
    # The HTML form must use enctype="multipart/form-data"
    if "file" not in request.files:
        return "No file part", 400
    f = request.files["file"]
    if f.filename == "":
        return "No file selected", 400
    contents = f.read().decode()
    return f"Got {f.filename} ({len(contents)} bytes)"

# Simulate a browser upload with an in-memory file:
with app.test_client() as client:
    data = {"file": (io.BytesIO(b"hello world"), "notes.txt")}
    r = client.post("/upload", data=data, content_type="multipart/form-data")
    print(r.data.decode())   # Got notes.txt (11 bytes)
    # Missing file -> 400
    print(client.post("/upload", content_type="multipart/form-data").status_code)  # 400

# app.run() would serve this forever, so it goes last and stays commented
# out — the test client above is how Flask itself exercises routes:
#     if __name__ == "__main__":
#         app.run(debug=True)

# ✅ Expected output:
# Got notes.txt (11 bytes)
# 400

We use io.BytesIO to fake a file in memory so the example runs without touching your disk. A real browser would send the same multipart request when the user picks a file and submits the form.

2 Sanitizing Names & Validating the Type

Never trust a filename from the browser. A malicious user could submit a name like ../../etc/passwd to try to escape your upload folder, or upload an executable disguised with a misleading name. Two defenses handle this: sanitize the name and validate the extension.

secure_filename from werkzeug.utils strips dangerous path parts and odd characters, turning ../my report.txt into a safe my_report.txt. To restrict the kinds of files you accept, keep an allow-list of extensions and reject anything outside it before saving. The example below does both and rejects an .exe upload outright.

# app.py — sanitize the name and allow only certain extensions
import io
from flask import Flask, request
from werkzeug.utils import secure_filename

app = Flask(__name__)
ALLOWED = {"txt", "png", "jpg"}

def allowed_file(filename):
    return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED

@app.route("/upload", methods=["POST"])
def upload():
    f = request.files["file"]
    if not allowed_file(f.filename):
        return "File type not allowed", 400
    safe = secure_filename(f.filename)   # strips dangerous path parts
    # In a real app: f.save(os.path.join(UPLOAD_DIR, safe))
    return f"Saved as {safe}"

with app.test_client() as client:
    # secure_filename sanitizes "../my report.txt" -> "my_report.txt"
    data = {"file": (io.BytesIO(b"x"), "../my report.txt")}
    print(client.post("/upload", data=data, content_type="multipart/form-data").data.decode())  # Saved as my_report.txt
    bad = {"file": (io.BytesIO(b"x"), "evil.exe")}
    r = client.post("/upload", data=bad, content_type="multipart/form-data")
    print(r.status_code, r.data.decode())  # 400 File type not allowed

# app.run() would serve this forever, so it goes last and stays commented
# out — the test client above is how Flask itself exercises routes:
#     if __name__ == "__main__":
#         app.run(debug=True)

# ✅ Expected output:
# Saved as my_report.txt
# 400 File type not allowed

Checking the extension is a cheap first line of defense, but it only inspects the name, not the actual contents. For higher-stakes apps you'd also verify the file's real type — but an allow-list plus secure_filename already blocks the most common mistakes.

3 Limiting the Upload Size

Without a size cap, a single user could upload a multi-gigabyte file and exhaust your server's disk or memory. Flask gives you a one-line guard: set app.config["MAX_CONTENT_LENGTH"] to the maximum number of bytes you'll accept.

When a request body exceeds that limit, Flask rejects it automatically with a 413 Request Entity Too Large response — before your view function ever runs. The demo below uses a deliberately tiny limit so you can watch a small upload succeed and a larger one get blocked.

# app.py — cap the upload size with MAX_CONTENT_LENGTH
import io
from flask import Flask, request

app = Flask(__name__)
# Reject any request whose total body is larger than 256 bytes (tiny, for the demo).
# In production you might use something like 16 * 1024 * 1024 for 16 MB.
app.config["MAX_CONTENT_LENGTH"] = 256

@app.route("/upload", methods=["POST"])
def upload():
    f = request.files["file"]
    return f"OK: {f.filename}"

with app.test_client() as client:
    # A small file fits comfortably under the limit
    small = {"file": (io.BytesIO(b"hi"), "a.txt")}
    print(client.post("/upload", data=small, content_type="multipart/form-data").status_code)  # 200
    # A larger upload is rejected before it reaches your view
    big = {"file": (io.BytesIO(b"x" * 500), "b.txt")}
    print(client.post("/upload", data=big, content_type="multipart/form-data").status_code)  # 413

# app.run() would serve this forever, so it goes last and stays commented
# out — the test client above is how Flask itself exercises routes:
#     if __name__ == "__main__":
#         app.run(debug=True)

# ✅ Expected output:
# 200
# 413

The small upload returns 200; the 500-byte upload returns 413 because its multipart body sails past the 256-byte cap. Note the limit covers the whole request body, not just the file's raw bytes, so pick a value with a little headroom.

🔬 File Upload Cheat Sheet

You want to...Use
Let a form send filesenctype="multipart/form-data"
Get an uploaded filerequest.files["file"]
Sanitize the filenamesecure_filename(name)
Save it to diskfile.save(path)
Validate the typename.rsplit(".", 1)[1] in allow-list
Limit the sizeMAX_CONTENT_LENGTH

A safe upload route does all five: right enctype, read from request.files, sanitize, validate the extension, and cap the size.

🎯 Your Turn: Fill in the Blanks

Complete the route below. Replace each ___ so it reads the uploaded file, checks that one was chosen, and returns its name and size in bytes.

# 🎯 YOUR TURN: replace each ___ to finish the upload route
import io
from flask import Flask, request

app = Flask(__name__)

@app.route("/upload", methods=["POST"])
def upload():
    f = request.___["file"]            # which object holds uploaded files?
    if f.filename == "":               # was a file actually chosen?
        return "No file selected", 400
    size = len(f.___())                # read the bytes, then count them
    return f"{f.filename} is {size} bytes"

with app.test_client() as client:
    data = {"file": (io.BytesIO(b"hello"), "greeting.txt")}
    r = client.post("/upload", data=data, content_type="multipart/form-data")
    print(r.data.decode())   # greeting.txt is 5 bytes

Common Errors & Quick Reference

❌ Forgetting the enctype (file arrives empty)

If your form lacks enctype="multipart/form-data", the browser sends only the filename as text, so request.files is empty and your upload looks like it vanished. Add the enctype and use method="POST".

❌ 413 Request Entity Too Large

The upload exceeded MAX_CONTENT_LENGTH, so Flask rejected it before your view ran. Raise the limit if the file is legitimately large, or tell the user the maximum size up front.

TaskCode
Get an uploadrequest.files.get("file")
Sanitize the namesecure_filename(f.filename)
Save to diskf.save("/uploads/" + safe)
Cap size to 16 MBMAX_CONTENT_LENGTH = 16*1024*1024

🏆 Mini Challenge: A CSV Importer

Build a route that accepts a .csv upload and reports how many rows it contains.

# 🏆 Mini Challenge: accept a .csv upload and count its rows
import io
from flask import Flask, request

app = Flask(__name__)

@app.route("/import", methods=["POST"])
def import_csv():
    f = request.files["file"]
    # Only allow .csv files
    if not f.filename.lower().endswith(".csv"):
        return "Only .csv files are allowed", 400
    text = f.read().decode()
    # Count non-empty lines as rows
    rows = [line for line in text.splitlines() if line.strip()]
    return f"{len(rows)} rows imported"

# Try it:
with app.test_client() as client:
    csv_data = b"name,score\nAda,90\nGrace,95\nLinus,88"
    ok = {"file": (io.BytesIO(csv_data), "scores.csv")}
    print(client.post("/import", data=ok, content_type="multipart/form-data").data.decode())  # 4 rows imported
    bad = {"file": (io.BytesIO(b"nope"), "data.txt")}
    r = client.post("/import", data=bad, content_type="multipart/form-data")
    print(r.status_code, r.data.decode())  # 400 Only .csv files are allowed

❓ Frequently Asked Questions

Lesson complete — you can accept files safely!

You can wire up a multipart/form-data form, read uploads from request.files, sanitize names with secure_filename, save files to disk, validate extensions, and cap the upload size with MAX_CONTENT_LENGTH.

Practice quiz

Where do uploaded files appear in a Flask request?

  • request.form
  • request.files
  • request.args
  • request.data

Answer: request.files. request.files is a dict-like object keyed by the file input's name.

Which form attribute is required to actually send file bytes?

  • method='GET'
  • accept-charset='utf-8'
  • enctype='multipart/form-data'
  • type='file'

Answer: enctype='multipart/form-data'. Without enctype='multipart/form-data' the browser sends only the filename as text.

What does secure_filename do?

  • sanitizes a user-supplied filename for safe disk use
  • encrypts the file
  • compresses the upload
  • renames the file randomly

Answer: sanitizes a user-supplied filename for safe disk use. It strips path parts like ../ and odd characters, e.g. '../my report.txt' becomes 'my_report.txt'.

Which module provides secure_filename?

  • flask.utils
  • os.path
  • flask.security
  • werkzeug.utils

Answer: werkzeug.utils. secure_filename is imported from werkzeug.utils.

How do you save an uploaded FileStorage object to disk?

  • f.save(path)
  • f.write(path)
  • save(f, path)
  • f.store(path)

Answer: f.save(path). The FileStorage object exposes .save(path) to write the file to disk.

Which config setting caps the maximum upload size?

  • UPLOAD_LIMIT
  • MAX_CONTENT_LENGTH
  • MAX_FILE_SIZE
  • CONTENT_CAP

Answer: MAX_CONTENT_LENGTH. app.config['MAX_CONTENT_LENGTH'] limits the request body in bytes.

What status does Flask return when an upload exceeds MAX_CONTENT_LENGTH?

  • 404 Not Found
  • 400 Bad Request
  • 413 Request Entity Too Large
  • 500 Internal Server Error

Answer: 413 Request Entity Too Large. Flask automatically returns 413 before your view runs.

What does FileStorage.filename hold?

  • the original name the browser sent
  • the absolute server path
  • the file's bytes
  • the MIME type

Answer: the original name the browser sent. .filename is the original client-supplied name; sanitize it before using it.

Why validate the file extension against an allow-list?

  • to make uploads faster
  • to compress images
  • it is required by HTTP
  • to reject unwanted or dangerous file types

Answer: to reject unwanted or dangerous file types. An allow-list of extensions rejects file types you do not want to accept.

How do you safely read an upload that may be missing?

  • request.files.get('file')
  • request.files['file'] always
  • request.get('file')
  • request.form['file']

Answer: request.files.get('file'). request.files.get('file') returns None instead of raising a KeyError when absent.

Continue this course

Frequently asked questions

How do I get an uploaded file in Flask?

Uploaded files arrive in request.files, a dictionary keyed by the name attribute of the file input. Read one with request.files["file"] (or the safer request.files.get("file")). The value is a FileStorage object that exposes .filename, .read(), and .save(path), so you can inspect, read, or persist the upload.

Why is my uploaded file empty or missing?

The most common cause is forgetting enctype="multipart/form-data" on the HTML form. Without it the browser sends only the file's name as plain text, not its bytes, so request.files is empty. Always set the enctype on any form that contains a file input, and submit it with the POST method.

What does secure_filename do and why do I need it?

secure_filename from werkzeug.utils sanitizes a user-supplied filename so it is safe to use on disk. It strips directory components like ../, removes unusual characters, and replaces spaces, turning something like ../my report.txt into my_report.txt. This prevents path-traversal attacks where a malicious name could overwrite files elsewhere on your server.

How do I limit the size of uploads?

Set app.config["MAX_CONTENT_LENGTH"] to a maximum number of bytes. Flask checks the incoming request body against this limit and automatically returns a 413 Request Entity Too Large response before your view runs, so an attacker cannot exhaust your disk or memory by uploading a huge file. Combine it with an allowed-extension check for defense in depth.