Error Handling

Sooner or later, every route hits a case it wasn't built for — a book id that doesn't exist, a shelf a visitor doesn't own, a bug that raises an exception nobody anticipated. Flask handles all of these by default, but its default pages are bare and generic. This lesson covers registering your own handlers for specific status codes and exceptions, so the Bookshelf app can fail in a way that still looks and feels like the Bookshelf app.

Flask's Default Error Pages (And Why You'd Replace Them)

Out of the box, a 404 in Flask renders a minimal HTML page with the text "Not Found" and nothing else — no navigation, no branding, no link back into the app. A 500 looks similarly plain. That's fine while you're first getting a project running, but it's a jarring dead end for a real visitor: they lose the header, the sidebar, any way back to the book they were trying to reach. Registering your own handler swaps that bare page for a full, styled template that still looks like the rest of the site.

A Custom 404 Handler

@app.errorhandler(404) registers a function that runs whenever Flask is about to return a 404 — whether that came from an unmatched route or from your own abort(404) call:

# app.py
@app.errorhandler(404)
def page_not_found(error):
    return render_template('errors/404.html'), 404

errors/404.html is an ordinary template — give it the same header, nav, and styling as the rest of Bookshelf, with a friendly message like "That book isn't on any shelf we can find" and a link back to /books. Note the return value is still a (body, status_code) tuple: rendering the template alone would default to a 200 status, which would be technically wrong for a page reporting a missing resource.

Note: These are real, correct Flask patterns, but this website's own project doesn't run a Flask server, so nothing here executes live on the page — read each example, then wire it into your own project.

A Custom 500 Handler And The Database Session

A 500 means something inside your own view code raised an exception Flask didn't know how to handle. A custom handler for it looks similar to the 404 case, but with one important extra step once a database is involved:

# app.py
@app.errorhandler(500)
def internal_server_error(error):
    db.session.rollback()
    return render_template('errors/500.html'), 500

The db.session.rollback() line matters more than it looks. If the exception happened partway through a database transaction — say, halfway through updating a Book's status and rating — the session can be left in a broken state where it still expects the failed change to be resolved. A 500 handler should never assume the session is clean; rolling it back first ensures the handler itself (and anything that runs after it) starts from a known-good state rather than inheriting the failure. This connects directly to how Flask-SQLAlchemy manages sessions, covered fully once the data layer lessons begin.

abort() For Manually Triggering An Error

abort() isn't only for 404s — it accepts any HTTP status code, and is the standard way to stop a view and return an error response from inside your own logic. A common case is a permission failure, where a visitor is logged in but isn't allowed to do the specific thing they're asking for:

# app.py
from flask import abort

@app.route('/shelves/<int:shelf_id>/delete', methods=['POST'])
def delete_shelf(shelf_id):
    shelf = Shelf.query.get_or_404(shelf_id)
    if shelf.user_id != current_user.id:
        abort(403)
    db.session.delete(shelf)
    db.session.commit()
    return redirect(url_for('shelf_list'))

abort(403) means "Forbidden" — the shelf exists, but this particular visitor doesn't own it. This exact check is where the Authorization & Custom Decorators lesson picks up, wrapping this kind of ownership check into a reusable decorator instead of repeating it in every view.

A Custom Exception Class With Its Own Handler

@app.errorhandler() isn't limited to HTTP status codes — you can register a handler for any Python exception class, including one you define yourself. This is a genuinely useful pattern for domain-specific failures that a plain status code doesn't describe well:

# app.py
class BookNotAvailableError(Exception):
    def __init__(self, book_title):
        self.book_title = book_title
        super().__init__(f'{book_title} is not available')

@app.errorhandler(BookNotAvailableError)
def handle_book_not_available(error):
    return render_template('errors/book_unavailable.html', book_title=error.book_title), 409

@app.route('/books/<int:book_id>/borrow', methods=['POST'])
def borrow_book(book_id):
    book = Book.query.get_or_404(book_id)
    if book.status == 'reading':
        raise BookNotAvailableError(book.title)
    book.status = 'reading'
    db.session.commit()
    return redirect(url_for('book_detail', book_id=book.id))

Instead of the view itself deciding what page to show when a book is already being read, it simply raises a BookNotAvailableError and lets the registered handler take over — the same handler runs no matter which view raises that exception, keeping the "what does this failure look like to the user" decision in one place. Status 409 ("Conflict") fits this case well: the request was valid, but the current state of the resource makes it impossible to fulfill.

Development Versus Production Error Behavior

While app.run(debug=True) is active, an unhandled exception doesn't show your custom 500 page at all — Flask's interactive debugger takes over instead, showing the full traceback in the browser with the ability to inspect variables at each stack frame. That's exactly what you want while building the app, but it would be a serious information leak in front of real visitors, potentially exposing source code, file paths, or configuration. Production deployments always run with debug mode off, so genuine errors fall through to your registered @app.errorhandler(500) and show a clean, generic page instead of a stack trace. This distinction returns in more depth in Security Best Practices and Deployment later in the track.

Next up: Every custom error page above was rendered with render_template() — Jinja2 Templates covers the templating engine itself, from basic variable output through the tags and filters that power every template in this app.