Flask at Scale: Config, Extensions & Migrations
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
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.txtInstall 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
- Write configuration as classes. Subclasses change only what differs, and production refuses to start without a secret:
# 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")- Create every extension object in
extensions.pywithout an app. This is the key to avoiding circular imports: models and blueprints importdbfrom here, never from__init__.py.
# 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- 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.
# 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 appfrom_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:
flask --app tiffin db init
flask --app tiffin db migrate -m "users, dishes, orders"
flask --app tiffin db upgradeINFO [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:
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")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.
# 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:
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:
# 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}")flask --app tiffin seed
flask --app tiffin create-user farhan@punetiffin.in
flask --app tiffin --helpAdded 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.
# 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())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 importingdbfrom__init__.py. Both lead to circular imports; create them inextensions.pyand bind withinit_app(). - Changing models without a migration. Production tables do not change; run
flask db migrate, review the script, thenflask db upgrade. - Trusting
nextblindly after login. That is an open redirect; allow only local paths. - One config with
ifstatements 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 withapp.config.from_object(). - Create extensions once in
extensions.pyand bind them in the factory withinit_app(). - Flask-Migrate gives
flask db init,migrate,upgrade,downgradeandcurrent; always review generated scripts. - Flask-Login provides
login_user(),logout_user(),current_userand@login_required, with auser_loaderandUserMixin. - Put admin jobs in
flaskcommands with Click; the factory makes per-test apps and a clearwsgi.pyentry 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
# 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")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()?
The init_app pattern separates creating an extension from attaching it to an app.
2. You added a column to a model. Which commands update the production database safely?
Migrations change existing tables without losing data.
3. After login, the app redirects to the next parameter. What risk must you guard against?
Only allow local paths for next.
4. What does @login_manager.user_loader do?
Flask-Login stores only the ID and asks your function to load the user.
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
