File Uploads

Text fields cover most of a book's data, but a nice touch for Bookshelf is letting a user attach a cover image when adding a book. File uploads bring their own set of rules that plain text fields don't: a different HTML form attribute, a different way of reading the submitted data, and — critically — a filename that came from an untrusted visitor and needs to be treated with suspicion before it ever touches your disk.

Adding a File Field to the Form

Flask-WTF ships a FileField, plus a FileAllowed validator that restricts which file extensions are accepted:

# forms.py
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed
from wtforms import StringField, IntegerField, SubmitField
from wtforms.validators import DataRequired

class AddBookForm(FlaskForm):
    title = StringField('Title', validators=[DataRequired()])
    pages = IntegerField('Pages')
    cover_image = FileField(
        'Cover Image',
        validators=[FileAllowed(['jpg', 'png'], 'Images only!')],
    )
    submit = SubmitField('Add Book')

FileAllowed checks the uploaded file's extension against the given list and attaches its message — 'Images only!' — as a field error if it doesn't match, using the exact same error-display pattern covered in the previous lesson, Form Validation & CSRF.

The Required enctype="multipart/form-data"

This is the single most common mistake beginners hit with file uploads: an HTML form's default encoding cannot carry binary file data at all. Unless the <form> tag explicitly opts into a different encoding, the browser will submit everything as plain text — silently dropping the file, or sending only its filename as a string. The fix is one attribute:

<form method="POST" enctype="multipart/form-data">
    {{ form.hidden_tag() }}

    {{ form.title.label }} {{ form.title() }}
    {{ form.pages.label }} {{ form.pages() }}
    {{ form.cover_image.label }} {{ form.cover_image() }}
    {{ form.submit() }}
</form>
Common mistake: if an upload form seems to "lose" the file — the view runs, but the uploaded file is never there — check enctype="multipart/form-data" on the <form> tag first. It's easy to leave out, and nothing raises an error when you do — the file just silently isn't sent.

Accessing the Uploaded File in a View

A submitted file is available two ways: raw, through Flask's request.files dictionary (keyed by field name), or — more conveniently, when using Flask-WTF — through the field's own .data attribute, which is the same underlying file object:

# both of these refer to the same uploaded file
uploaded = request.files['cover_image']
# or, equivalently, once you have the form instance:
uploaded = form.cover_image.data

Why a Filename Can't Be Trusted As-Is

The uploaded file object's .filename attribute holds whatever name the visitor's own browser sent — and that string came from the visitor's machine, not yours. A user (or worse, a script deliberately probing your app) could submit a file whose name is ../../etc/passwd or something similarly crafted. If that string were used directly to build a save path on your server, a name like that could walk the path upward out of your intended upload folder and overwrite or expose a file completely unrelated to Bookshelf — a classic directory traversal vulnerability. This is a genuine, well-known attack, not a theoretical one, and it's exactly why raw filenames are never safe to use directly.

Werkzeug (the library Flask is built on) ships a function precisely for this: secure_filename(). It strips path separators, leading dots, and anything else that isn't a plain, safe filename character, leaving something guaranteed to save inside the folder you intend:

from werkzeug.utils import secure_filename

secure_filename('../../etc/passwd')     # -> 'etc_passwd'
secure_filename('my cover photo.jpg')   # -> 'my_cover_photo.jpg'

Full Worked Example: Validating, Sanitizing, and Saving

Putting it together — the view for adding a book with a cover image, saving it into a configured UPLOAD_FOLDER:

# app.py
import os
import uuid
from werkzeug.utils import secure_filename
from flask import Flask, render_template, redirect, url_for

app = Flask(__name__)
app.config['UPLOAD_FOLDER'] = os.path.join(app.static_folder, 'uploads')

@app.route('/books/add', methods=['GET', 'POST'])
def add_book():
    form = AddBookForm()

    if form.validate_on_submit():
        book = Book(title=form.title.data, pages=form.pages.data, user_id=current_user_id())

        uploaded = form.cover_image.data
        if uploaded:
            safe_name = secure_filename(uploaded.filename)
            # prefix with a UUID so two uploads named cover.jpg never collide
            unique_name = f"{uuid.uuid4().hex}_{safe_name}"
            uploaded.save(os.path.join(app.config['UPLOAD_FOLDER'], unique_name))
            book.cover_image = unique_name

        db.session.add(book)
        db.session.commit()
        return redirect(url_for('book_list'))

    return render_template('add_book.html', form=form)

Two things stand out here. First, secure_filename() runs before the file ever touches the disk — never after. Second, a random UUID prefix is added on top of the sanitized name; this isn't a security measure, it's there so a second user uploading a file also named cover.jpg doesn't silently overwrite the first user's image. Only the final, sanitized, unique unique_name string is stored on the Book row — never the original, untrusted filename.

Note: This upload-handling code is correct, real Flask usage — but this website's own project doesn't run a Flask server, so no file is actually uploaded or saved on this page.

Serving the Uploaded Image Back

Since the cover images are saved inside a subfolder of static/, they're already reachable at a URL the same way any other static asset is — through url_for('static', ...), exactly as covered in Static Files & Assets:

<img src="{{ url_for('static', filename='uploads/' + book.cover_image) }}" alt="{{ book.title }} cover">

If uploads instead needed to live somewhere outside static/ — say, to keep them separate from version-controlled assets, or to add access checks before serving a file — a dedicated view function reading the file from disk and returning it (via Flask's send_from_directory()) would take the place of this url_for() call. For a personal project like Bookshelf, though, serving cover images straight out of static/uploads/ is simple and perfectly adequate.

Limiting Upload Size

Nothing so far stops a visitor from attempting to upload an enormous file — accidentally or maliciously — and tying up server memory and disk space in the process. Flask's MAX_CONTENT_LENGTH config value caps the total size of any incoming request body; anything larger is rejected automatically with a 413 "Request Entity Too Large" response, before your view code even runs:

app.config['MAX_CONTENT_LENGTH'] = 5 * 1024 * 1024  # 5 MB, applies to every request body

For a cover-image upload, something in the low single-digit megabytes is generous — a sensible default to set once, application-wide, and forget about.

Coming up next: Sessions & Cookies covers how Flask remembers information about a visitor across requests — starting with why HTTP needs that help in the first place.