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>
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.
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.