EthicsComputerCourses
Join training +91 90532 10052

Flask at Scale: Config, Extensions & Migrations

Django & Flask · Lesson 12 of 42 Intermediate 8 min read Updated

Blueprints & the App Factory and Flask with SQLAlchemy gave Pune Tiffin a package, a create_app() function and a database. Real Flask projects add a few more pieces that every team ends up writing: separate configuration for development, tests and production; one place where all extensions are created; migrations so the database can change without losing data; a proper login system; and command-line tools for jobs like seeding data.

Flask stays small on purpose and leaves these choices to you, which is both its strength and its trap: two Flask projects can look completely different. This lesson shows the layout most experienced Flask developers settle on, with Flask-Migrate, Flask-Login and custom flask commands. Every output comes from running the app with Flask 3.1, Flask-SQLAlchemy 3.1, Flask-Migrate 4.1 and Flask-Login 0.6.

The layout

Text
pune-tiffin/
    tiffin/
        __init__.py       create_app()
        config.py         Config, TestConfig, ProdConfig
        extensions.py     db, migrate, login_manager (created, not yet bound)
        models.py
        auth.py           blueprint: login, logout
        menu.py           blueprint
        orders.py         blueprint
        cli.py            custom flask commands
    migrations/           generated by Flask-Migrate, committed to Git
    tests/
    wsgi.py               production entry point
    requirements.txt

Install the extra packages: python -m pip install flask-migrate flask-login. Flask-Migrate brings Alembic, SQLAlchemy's migration tool.

Step by step: config classes and extensions

  1. Write configuration as classes. Subclasses change only what differs, and production refuses to start without a secret:
Python
# tiffin/config.py
import os


class Config:
    SECRET_KEY = os.environ.get("TIFFIN_SECRET_KEY", "dev-only-change-me")
    SQLALCHEMY_DATABASE_URI = os.environ.get("TIFFIN_DATABASE_URL", "sqlite:///tiffin.db")
    DELIVERY_FEE = 20


class TestConfig(Config):
    TESTING = True
    SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"


class ProdConfig(Config):
    SESSION_COOKIE_SECURE = True
    SESSION_COOKIE_SAMESITE = "Lax"
    REMEMBER_COOKIE_SECURE = True

    def __init__(self):
        if "TIFFIN_SECRET_KEY" not in os.environ:
            raise RuntimeError("Set TIFFIN_SECRET_KEY in production")
  1. Create every extension object in extensions.py without an app. This is the key to avoiding circular imports: models and blueprints import db from here, never from __init__.py.
Python
# tiffin/extensions.py
from flask_login import LoginManager
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import DeclarativeBase


class Base(DeclarativeBase):
    pass


db = SQLAlchemy(model_class=Base)
migrate = Migrate()
login_manager = LoginManager()
login_manager.login_view = "auth.login"        # where @login_required sends visitors
  1. Bind them inside the factory with init_app(). This init_app pattern is how almost every Flask extension supports the factory: one extension object can serve many app instances, such as a test app and a real one.
Python
# tiffin/__init__.py
from flask import Flask

from .config import Config
from .extensions import db, login_manager, migrate


def create_app(config_class=Config):
    app = Flask(__name__)
    app.config.from_object(config_class)

    db.init_app(app)
    migrate.init_app(app, db)
    login_manager.init_app(app)

    from . import auth, cli, menu, orders
    app.register_blueprint(auth.bp, url_prefix="/auth")
    app.register_blueprint(menu.bp)
    app.register_blueprint(orders.bp, url_prefix="/orders")
    cli.register(app)

    return app

from_object() copies every UPPERCASE attribute of a class or an object into app.config. Passing the class (ProdConfig) does not run __init__; passing an instance (ProdConfig()) does, which is how the secret check runs in production.

Migrations with Flask-Migrate

db.create_all() only creates missing tables; it never adds a column to an existing one. Flask-Migrate adds the flask db commands, which compare your models with the database and write migration scripts, just like Django's makemigrations:

Terminal
flask --app tiffin db init
flask --app tiffin db migrate -m "users, dishes, orders"
flask --app tiffin db upgrade
Output
INFO  [alembic.autogenerate.compare.tables] Detected added table 'dish'
INFO  [alembic.autogenerate.compare.tables] Detected added table 'user'
INFO  [alembic.autogenerate.compare.tables] Detected added table 'orders'
Generating ...\migrations\versions\0fbb4b549e0f_users_dishes_orders.py ...  done
INFO  [alembic.runtime.migration] Running upgrade  -> 0fbb4b549e0f, users, dishes, orders

db init runs once and creates the migrations/ folder (commit it). Later, add a veg flag to Dish. Existing rows need a value, so give the column a server default, which the database itself uses:

Python
class Dish(db.Model):
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(80), unique=True)
    price: Mapped[int]
    veg: Mapped[bool] = mapped_column(default=True, server_default="1")
Output
INFO  [alembic.autogenerate.compare.tables] Detected added column 'dish.veg'
Generating ...\migrations\versions\b60eb0654678_dish_veg_flag.py ...  done
INFO  [alembic.runtime.migration] Running upgrade 0fbb4b549e0f -> b60eb0654678, dish veg flag
b60eb0654678 (head)

The last line is flask db current, showing the database is at the newest revision ("head"). Always read a generated script before applying it: Alembic's autogenerate cannot detect everything (for example, it sees a renamed column as one column dropped and another added, which would lose data). flask db downgrade steps back one revision.

Logins with Flask-Login

The hand-made session login from Sessions, Cookies & Flash Messages works, but Flask-Login handles the details: remembering the user ID in the session, loading the user on each request, current_user in views and templates, and @login_required.

Python
# tiffin/models.py (user part)
from flask_login import UserMixin
from werkzeug.security import check_password_hash, generate_password_hash

from .extensions import db, login_manager


class User(UserMixin, db.Model):
    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(String(120), unique=True)
    password_hash: Mapped[str] = mapped_column(String(256))

    def set_password(self, password):
        self.password_hash = generate_password_hash(password)

    def check_password(self, password):
        return check_password_hash(self.password_hash, password)


@login_manager.user_loader
def load_user(user_id):
    return db.session.get(User, int(user_id))


# tiffin/auth.py (the POST part of the login view)
user = db.session.execute(db.select(User).filter_by(email=request.form["email"])).scalar_one_or_none()
if user and user.check_password(request.form["password"]):
    login_user(user)
    next_url = request.args.get("next")
    if not next_url or not next_url.startswith("/"):    # only local redirects
        next_url = url_for("menu.index")
    return redirect(next_url)
flash("Wrong email or password.")


# tiffin/orders.py
@bp.get("/")
@login_required
def list_orders():
    count = db.session.scalar(db.select(db.func.count(Order.id)))
    return f"{count} orders today. Logged in as {current_user.email}"

UserMixin supplies the properties Flask-Login expects (is_authenticated, get_id() and others). The user_loader turns the ID stored in the session back into a user. A session with the test client:

Output
GET  /orders/                                  302 -> /auth/login?next=%2Forders%2F
POST /auth/login?next=/orders/ (wrong pwd)     200 form shown again
POST /auth/login?next=/orders/                 302 -> /orders/
GET  /orders/                                  0 orders today. Logged in as farhan@punetiffin.in
POST /auth/login?next=https://evil.example/    302 -> /
POST /auth/logout, then GET /orders/           302

The fifth line matters for security: an attacker can send a login link with next=https://evil.example/ to bounce users to a fake site after a real login (an open redirect). Accepting only paths that start with / blocks the simple form of that trick; a stricter check also rejects values starting with //, which browsers treat as another host. Logout is POST-only, like Django's, so a link on another site cannot log your users out.

Custom CLI commands

Jobs such as seeding the menu or creating a staff account belong in flask commands, not in throwaway scripts. Commands registered on app.cli run inside the app context, so db works:

Python
# tiffin/cli.py
import click

from .extensions import db
from .models import Dish, User


def register(app):
    @app.cli.command("seed")
    def seed():
        """Add the starting menu."""
        for name, price in [("Rajma chawal", 120), ("Veg pulao", 100), ("Poha", 40)]:
            db.session.add(Dish(name=name, price=price))
        db.session.commit()
        click.echo("Added 3 dishes.")

    @app.cli.command("create-user")
    @click.argument("email")
    @click.password_option()
    def create_user(email, password):
        """Create a staff login."""
        user = User(email=email)
        user.set_password(password)
        db.session.add(user)
        db.session.commit()
        click.echo(f"Created {email}")
Terminal
flask --app tiffin seed
flask --app tiffin create-user farhan@punetiffin.in
flask --app tiffin --help
Output
Added 3 dishes.
Password: 
Repeat for confirmation: 
Created farhan@punetiffin.in
Commands:
  create-user  Create a staff login.
  db           Perform database migrations.
  routes       Show the routes for the app.
  run          Run a development server.
  seed         Add the starting menu.
  shell        Run a shell in the app context.

click.password_option() asks for the password twice without showing it, so it never lands in your shell history. The docstring becomes the help text. flask routes lists every endpoint, which is handy when a blueprint's URL is not what you expected.

Tests and production entry point

The factory makes test apps cheap: each test gets a fresh app with an in-memory database.

Python
# tests/conftest.py
import pytest

from tiffin import create_app
from tiffin.config import TestConfig
from tiffin.extensions import db
from tiffin.models import User


@pytest.fixture
def app():
    app = create_app(TestConfig)
    with app.app_context():
        db.create_all()
        user = User(email="isha@punetiffin.in")
        user.set_password("Test-Pass-123")
        db.session.add(user)
        db.session.commit()
        yield app
        db.drop_all()


# wsgi.py (production entry point: gunicorn wsgi:app)
from tiffin import create_app
from tiffin.config import ProdConfig

app = create_app(ProdConfig())
Output
tests/test_auth.py::test_orders_need_login PASSED                        [ 33%]
tests/test_auth.py::test_login_then_orders PASSED                        [ 66%]
tests/test_auth.py::test_prod_config_refuses_missing_secret PASSED       [100%]

============================== 3 passed in 1.63s ==============================

Tests use create_all() for speed; real databases change only through migrations. In production, gunicorn wsgi:app --bind 127.0.0.1:8001 --workers 3 loads wsgi.py, and importing it without TIFFIN_SECRET_KEY fails with RuntimeError: Set TIFFIN_SECRET_KEY in production before the server accepts a single request.

Common mistakes

  • Creating extensions inside create_app() or importing db from __init__.py. Both lead to circular imports; create them in extensions.py and bind with init_app().
  • Changing models without a migration. Production tables do not change; run flask db migrate, review the script, then flask db upgrade.
  • Trusting next blindly after login. That is an open redirect; allow only local paths.
  • One config with if statements everywhere. Use classes and choose one in the entry point.
  • Logout by GET link. Use POST, protected by CSRF in real forms.

Key points

  • Keep configuration in classes (Config, TestConfig, ProdConfig) and load one with app.config.from_object().
  • Create extensions once in extensions.py and bind them in the factory with init_app().
  • Flask-Migrate gives flask db init, migrate, upgrade, downgrade and current; always review generated scripts.
  • Flask-Login provides login_user(), logout_user(), current_user and @login_required, with a user_loader and UserMixin.
  • Put admin jobs in flask commands with Click; the factory makes per-test apps and a clear wsgi.py entry point easy.

Exercise

Add an api blueprint at /api with two endpoints: GET /api/menu, returning the dishes as JSON (name, price, veg), and GET /api/dish/<id>, returning one dish or a 404. Make errors raised inside this blueprint return JSON like {"error": "Not Found", "status": 404} instead of an HTML page, using @bp.errorhandler(HTTPException). Test a good request, a missing dish and a POST to /api/menu, and explain the result of the last one.

Show solution
Python
# tiffin/api.py
from flask import Blueprint
from werkzeug.exceptions import HTTPException

from .extensions import db
from .models import Dish

bp = Blueprint("api", __name__)


@bp.get("/menu")
def menu():
    dishes = db.session.execute(db.select(Dish).order_by(Dish.price)).scalars()
    return {"dishes": [{"name": d.name, "price": d.price, "veg": d.veg} for d in dishes]}


@bp.get("/dish/<int:dish_id>")
def dish(dish_id):
    d = db.get_or_404(Dish, dish_id)
    return {"name": d.name}


@bp.errorhandler(HTTPException)
def json_error(error):
    return {"error": error.name, "status": error.code}, error.code


# in create_app():  app.register_blueprint(api.bp, url_prefix="/api")
Output
GET  /api/menu     200 {"dishes":[{"name":"Poha","price":40,"veg":true},{"name":"Veg pulao","price":100,"veg":true},{"name":"Rajma chawal","price":120,"veg":true}]}
GET  /api/dish/99  404 {"error":"Not Found","status":404}
POST /api/menu     405 <!doctype html> ... <title>405 Method Not Allowed</title> ...

Returning a dictionary from a Flask view makes a JSON response automatically, and db.get_or_404() raises a 404 that the blueprint handler turns into JSON. The POST still gets HTML: a 405 (and a 404 for a URL that matches no route) is raised while Flask is routing the request, before it knows which blueprint should handle it, so blueprint handlers never see it. Register an app-level handler with @app.errorhandler(HTTPException) that returns JSON when request.path starts with /api/.

Quick Quiz

1. Why are extension objects such as db created in extensions.py and bound later with init_app()?

2. You added a column to a model. Which commands update the production database safely?

3. After login, the app redirects to the next parameter. What risk must you guard against?

4. What does @login_manager.user_loader do?

Learn Django & Flask with a mentor

Free lessons get you started. Our Python Programming training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call