Flask Blueprints, Factories, and Production Layout
1 · The lesson
readThe single-file app.py from earlier lessons works beautifully — until it doesn't. Around the time you hit twenty routes, three forms, a half-dozen models, and need different configs for tests and production, the monolith starts to fight back. Imports tangle. The same app global is referenced from six places. You can't test with a different database without monkey-patching.
This lesson covers the grown-up Flask layout: the application factory pattern, blueprints for modular routing, config classes for environment switching, testing with pytest, and production deployment with gunicorn. It's the structure every Flask project of consequence eventually adopts.
Run locally with
pip install flask flask-sqlalchemy flask-migrate gunicorn(waitress on Windows). Expected output is shown in comments. The production server (gunicorn) is Linux/macOS only — Windows useswaitress.
1. The Problem with One Big app.py
A single file Flask app has four growing pains:
- Circular imports. Your
models.pywantsdbfromapp.py. Yourapp.pywantsUserfrommodels.py. Boom. - One global
app. You can't easily create a second one for tests with a different config — the import has side effects. - No separation of concerns. Auth routes, billing routes, and admin routes all share one file. Twenty contributors editing the same file = merge hell.
- Config is hard-coded. "Use Postgres in production and SQLite in tests" turns into spaghetti without a structure.
The fix is two patterns working together: blueprints to split routes into modules, and the application factory to build the app on demand.
2. The Recommended Layout
myapp/ ├── __init__.py # contains create_app() — the factory ├── config.py # Config, DevelopmentConfig, ProductionConfig, TestingConfig ├── extensions.py # db = SQLAlchemy(), migrate = Migrate(), etc. ├── models/ │ ├── __init__.py │ ├── user.py │ └── post.py ├── blueprints/ │ ├── __init__.py │ ├── main/ │ │ ├── __init__.py # bp = Blueprint("main", __name__) │ │ ├── routes.py │ │ └── templates/main/ │ ├── auth/ │ │ ├── __init__.py # bp = Blueprint("auth", __name__, url_prefix="/auth") │ │ ├── routes.py │ │ ├── forms.py │ │ └── templates/auth/ │ └── api/ │ ├── __init__.py │ └── routes.py ├── templates/ │ └── base.html # shared layout ├── static/ │ ├── style.css │ └── app.js ├── tests/ │ ├── conftest.py │ ├── test_main.py │ └── test_auth.py ├── migrations/ # created by `flask db init` ├── wsgi.py # production entrypoint: `app = create_app()` └── requirements.txt
Names aren't sacred — what matters is the shape:
__init__.pyis the entry point. Nothing executes at import time except trivial assignments.- Extensions (
db,migrate,login_manager, ...) are module-level singletons created uninitialised.init_app(app)runs inside the factory. - Each blueprint is a self-contained folder: routes, forms, templates for that feature.
3. The Application Factory
# myapp/__init__.py from flask import Flask from .extensions import db, migrate, csrf from .config import Config def create_app(config_class=Config): app = Flask(__name__) app.config.from_object(config_class) # Initialise extensions against this specific app db.init_app(app) migrate.init_app(app, db) csrf.init_app(app) # Register blueprints from .blueprints.main import bp as main_bp from .blueprints.auth import bp as auth_bp from .blueprints.api import bp as api_bp app.register_blueprint(main_bp) app.register_blueprint(auth_bp) app.register_blueprint(api_bp, url_prefix="/api") # Error handlers (could also live in a blueprint) @app.errorhandler(404) def not_found(_e): from flask import render_template return render_template("errors/404.html"), 404 return app
What changed:
create_app()is a function. Call it to get a fresh app. Tests can call it with a different config. Multi-tenant deployments can call it per tenant.- Imports are inside the function.
from .blueprints.main import bpruns lazily, afterdbhas been initialised. No circular-import errors. config_class=Config— pick the config you want.Configis the default; tests passTestingConfig.
# myapp/extensions.py from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate from flask_wtf.csrf import CSRFProtect from sqlalchemy.orm import DeclarativeBase class Base(DeclarativeBase): pass db = SQLAlchemy(model_class=Base) migrate = Migrate() csrf = CSRFProtect()
Extensions are created once at module level and initialised against an app per-app-creation. This is the pattern that makes the factory work.
4. Defining a Blueprint
A blueprint is a mini-Flask: a place to register routes, error handlers, template folders, and static folders that get attached to the real app later.
# myapp/blueprints/auth/__init__.py from flask import Blueprint bp = Blueprint( "auth", # name — used in url_for("auth.login") __name__, url_prefix="/auth", # all routes here mount under /auth template_folder="templates", # blueprint-local templates ) from . import routes # noqa: E402 — register routes by importing
# myapp/blueprints/auth/routes.py from flask import render_template, redirect, url_for, flash from . import bp from .forms import LoginForm @bp.get("/login") @bp.post("/login") def login(): form = LoginForm() if form.validate_on_submit(): # ... authenticate ... flash("Logged in.", "success") return redirect(url_for("main.home")) # note: dotted blueprint name return render_template("auth/login.html", form=form) @bp.get("/logout") def logout(): # ... log out ... return redirect(url_for("main.home"))
Three things to note:
@bp.route(or@bp.get,@bp.post) instead of@app.route. Same semantics.- The route
/loginbecomes/auth/loginbecause of the blueprint'surl_prefix. url_for("auth.login")— the dotted form is the blueprint name plus the view name. Get this wrong and Flask raisesBuildError.
5. Configuration Management
# myapp/config.py import os from pathlib import Path basedir = Path(__file__).resolve().parent.parent class Config: """Default config — shared baseline.""" SECRET_KEY = os.environ.get("SECRET_KEY", "dev-only-do-not-use-in-prod") SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", f"sqlite:///{basedir / 'app.db'}") SQLALCHEMY_TRACK_MODIFICATIONS = False MAX_CONTENT_LENGTH = 5 * 1024 * 1024 class DevelopmentConfig(Config): DEBUG = True SQLALCHEMY_ECHO = True # log SQL class TestingConfig(Config): TESTING = True SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:" # fresh DB per test session WTF_CSRF_ENABLED = False # let tests POST without CSRF tokens class ProductionConfig(Config): DEBUG = False # SECRET_KEY must come from env; if missing, fail loudly SECRET_KEY = os.environ["SECRET_KEY"] config_map = { "development": DevelopmentConfig, "testing": TestingConfig, "production": ProductionConfig, }
setup added so this can run · defines
import os # noqa: F401 os.environ.setdefault("SECRET_KEY", "example-secret-key") os.environ.setdefault("DATABASE_URL", "postgresql://user:password@localhost:5432/example")
The factory picks one:
def create_app(config_name=None): config_name = config_name or os.environ.get("FLASK_ENV", "development") config_class = config_map[config_name] app = Flask(__name__) app.config.from_object(config_class) ...
setup added so this can run · defines config_map, Flask, os
import os # noqa: F401 os.environ.setdefault("FLASK_ENV", "example-flask-env") # Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) config_map = _AutoMock('config_map') def Flask(*_a, **_kw): print('-> Flask() called') return _AutoMock('Flask()') os = _AutoMock('os')
Three rules I've learned the hard way:
- Production fails loudly on missing secrets.
os.environ["SECRET_KEY"]raises if unset — that's what you want. Don't fall back to a dev default in prod. - Tests get their own config. In-memory SQLite, CSRF off, debug off (debug-on tests can mask bugs).
- Never commit
.envfiles. Secrets live in env vars, set by your deployment platform. See devops-secrets.
6. The g and current_app Globals
Inside a request, current_app is the running Flask instance (the one the factory built), and g is a per-request scratchpad:
from flask import current_app, g, request @bp.before_request def load_user(): token = request.headers.get("Authorization") g.user = lookup_user(token) if token else None @bp.get("/profile") def profile(): if g.user is None: return "unauth", 401 return render_template("profile.html", user=g.user, app_name=current_app.config["APP_NAME"])
setup added so this can run · defines bp, render_template, lookup_user
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) bp = _AutoMock('bp') def render_template(*_a, **_kw): print('-> render_template() called') return _AutoMock('render_template()') def lookup_user(*_a, **_kw): print('-> lookup_user() called') return _AutoMock('lookup_user()')
glives for one request. Use it to memoise per-request state — the current user, a DB session, a feature-flag snapshot.current_applets blueprints access app config without importing the app directly (which would re-create the circular-import problem the factory was meant to solve).
Outside a request — in scripts, in tests — push a context manually:
from myapp import create_app app = create_app() with app.app_context(): from myapp.extensions import db db.create_all() # works because we're in an app context
7. Cross-Blueprint URL Building
The dotted form again — url_for("blueprint.view"):
# In a route or template inside the auth blueprint: return redirect(url_for("main.home")) # cross-blueprint return redirect(url_for(".login")) # same-blueprint shortcut # In a template: <a href="{{ url_for('api.users') }}">API</a>
A leading . means "this blueprint". url_for(".login") inside auth resolves to auth.login. Useful when you rename the blueprint.
8. Testing with pytest
app.test_client() is the in-process HTTP client. No real server needed; tests run in microseconds:
# tests/conftest.py import pytest from myapp import create_app from myapp.extensions import db @pytest.fixture def app(): app = create_app("testing") with app.app_context(): db.create_all() yield app db.session.remove() db.drop_all() @pytest.fixture def client(app): return app.test_client()
# tests/test_main.py def test_home_loads(client): r = client.get("/") assert r.status_code == 200 assert b"Items" in r.data def test_create_item_redirects(client): r = client.post("/items/new", data={"name": "test"}, follow_redirects=False) assert r.status_code == 302 assert r.headers["Location"].endswith("/") def test_create_item_persists(client, app): client.post("/items/new", data={"name": "first"}) with app.app_context(): from myapp.models import Item from sqlalchemy import select items = db.session.execute(select(Item)).scalars().all() assert len(items) == 1 assert items[0].name == "first"
setup added so this can run · defines db
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) db = _AutoMock('db')
A few patterns:
- Fresh app per test. The
appfixture creates a new app each test (not session-scoped) so test isolation is automatic. - CSRF off in TestingConfig. Tests post forms directly without rendering them first.
with app.app_context():in assertions that touch the DB outside the client call.
Run with pytest. The whole suite for a small app finishes in under a second.
9. Production WSGI Servers
The development server (app.run() / flask run) is single-threaded, slow, and unsafe in production. Real servers:
Gunicorn — the standard on Linux/macOS:
pip install gunicorn
# wsgi.py
from myapp import create_app
app = create_app("production")
# Start it
gunicorn --workers 4 --bind 0.0.0.0:8000 wsgi:app
# Or directly call the factory
gunicorn --workers 4 --bind 0.0.0.0:8000 'myapp:create_app()'Waitress — pure Python, Windows-friendly, no fork dependency:
pip install waitress waitress-serve --listen=0.0.0.0:8000 wsgi:app
Worker count rule of thumb: (2 × CPU cores) + 1. Each worker is a separate process; they share nothing in memory. CPU-bound work scales linearly with workers; I/O-bound work benefits more from async workers (gunicorn -k gevent or switching to FastAPI).
10. nginx in Front of Gunicorn
In production you put nginx (or another reverse proxy) in front of Gunicorn. nginx handles:
- TLS termination — HTTPS connections terminate at nginx; gunicorn talks plain HTTP on localhost.
- Static files —
static/and uploaded media served directly by nginx, never touching Python. - Connection buffering — slow clients on slow networks don't tie up Python workers.
- Compression, request limits, IP allowlists, request logging.
The shape:
Client → nginx :443 (TLS, static) → gunicorn :8000 (Python) → Postgres :5432
A minimal nginx server block:
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location /static/ {
alias /srv/myapp/static/;
expires 30d;
}
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Or skip the nginx setup entirely and put the app behind a managed platform — Fly.io, Railway, Render, AWS App Runner. They terminate TLS, run your container, and serve static assets without you writing nginx config. See devops-docker and devops-environments.
11. Common Mistakes
1. Circular imports
Symptom: ImportError: cannot import name 'db' from partially initialized module 'myapp.extensions'. Cause: models.py imports db at module top, and something db depends on imports models.py. Fix: keep extensions in extensions.py, import blueprints inside create_app(), and import models inside view functions if necessary.
2. Calling db.init_app(app) in a global
# WRONG — runs at import time, before there's a real app config db.init_app(some_global_app)
setup added so this can run · defines some_global_app, db
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) some_global_app = _AutoMock('some_global_app') db = _AutoMock('db')
Always call init_app() inside create_app(), where the right app and config exist. The whole point of the factory is to delay binding.
3. Inconsistent url_prefix
auth_bp = Blueprint("auth", __name__, url_prefix="/auth") # and also app.register_blueprint(auth_bp, url_prefix="/auth") # registered twice → /auth/auth/...
setup added so this can run · defines Blueprint, app
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def Blueprint(*_a, **_kw): print('-> Blueprint() called') return _AutoMock('Blueprint()') app = _AutoMock('app')
Pick one place to set the prefix — usually on the blueprint, not on the registration. Mixing the two doubles up the prefix.
4. Forgetting app.app_context() in scripts
# A standalone script that touches the DB from myapp import create_app from myapp.extensions import db app = create_app() db.create_all() # RuntimeError: no application context
Wrap any DB call in with app.app_context():. Inside a request, Flask pushes the context for you; outside, you do it.
5. Hardcoded blueprint names in url_for
url_for("login") # WRONG — needs the blueprint prefix url_for("auth.login") # RIGHT url_for(".login") # also right if you're inside auth
setup added so this can run · defines url_for
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def url_for(*_a, **_kw): print('-> url_for() called') return _AutoMock('url_for()')
The hardcoded form silently raises BuildError once the route lives in a blueprint. Pay attention to the dot.
6. Tests that share state
If your app fixture is session-scoped and tests create rows, test 5 can see test 3's data. Use function scope (default) for the app and db fixtures, or wrap each test in a transaction that gets rolled back. The latter is faster on big test suites — search "pytest savepoint" for the pattern.
7. Running gunicorn with debug or app.run() in production
Both serve traffic, but both are wrong for prod. app.run(debug=True) exposes the Werkzeug debugger (RCE-as-a-feature). app.run(debug=False) is single-threaded and slow. Use gunicorn (or waitress on Windows) for any deployment a real user touches. See security-checklist.
🎯 Your Turn — Refactor to Factory + Blueprints
Take this single-file app:
# old_app.py — what you start from from flask import Flask, render_template, request, redirect, url_for app = Flask(__name__) app.config["SECRET_KEY"] = "dev" users = {} next_uid = 1 @app.get("/") def home(): return render_template("home.html", count=len(users)) @app.get("/users/") def list_users(): return render_template("users_list.html", users=users.values()) @app.post("/users/") def create_user(): global next_uid name = request.form.get("name", "").strip() if name: users[next_uid] = {"id": next_uid, "name": name} next_uid += 1 return redirect(url_for("list_users")) if __name__ == "__main__": app.run(debug=True)
Refactor into:
myapp/ ├── __init__.py # create_app() factory ├── config.py # Config + DevelopmentConfig + TestingConfig ├── extensions.py # (empty for now — no DB yet) ├── blueprints/ │ ├── __init__.py │ ├── main/ │ │ ├── __init__.py # bp = Blueprint("main", __name__) │ │ └── routes.py │ └── users/ │ ├── __init__.py # bp = Blueprint("users", __name__, url_prefix="/users") │ └── routes.py └── templates/ ├── base.html ├── home.html └── users_list.html
Requirements:
create_app()accepts aconfig_class(or name).mainblueprint owns/.usersblueprint owns/users/withGETandPOST. The blueprint setsurl_prefix="/users"so the routes inside it are""(which becomes/users/).- All
url_forcalls in templates use the dotted form. - A
wsgi.pyat the project root that creates the app for gunicorn.
Hint 1 — Blueprint with empty route
A blueprint withurl_prefix="/users" wants routes like @bp.route("/") (which becomes /users/). Don't write @bp.route("/users/") — that would become /users/users/. The route inside the blueprint is relative to the prefix.
Hint 2 — Templates and url_for in the new layout
Templates can live in a top-leveltemplates/ folder (shared) — Flask finds them. In templates, references to list_users become url_for("users.list_users"). References to home become url_for("main.home"). Forget the prefix and you'll get BuildError on the first request.
Show full solution
# myapp/__init__.py from flask import Flask from .config import Config def create_app(config_class=Config): app = Flask(__name__) app.config.from_object(config_class) from .blueprints.main import bp as main_bp from .blueprints.users import bp as users_bp app.register_blueprint(main_bp) app.register_blueprint(users_bp) return app
# myapp/config.py import os class Config: SECRET_KEY = os.environ.get("SECRET_KEY", "dev-only") class DevelopmentConfig(Config): DEBUG = True class TestingConfig(Config): TESTING = True WTF_CSRF_ENABLED = False class ProductionConfig(Config): DEBUG = False SECRET_KEY = os.environ["SECRET_KEY"]
setup added so this can run · defines
import os # noqa: F401 os.environ.setdefault("SECRET_KEY", "example-secret-key")
# myapp/extensions.py # Empty for now — no SQLAlchemy in this exercise. Real apps populate this.
# myapp/blueprints/main/__init__.py from flask import Blueprint bp = Blueprint("main", __name__) from . import routes # noqa: E402
# myapp/blueprints/main/routes.py from flask import render_template from . import bp from ..users.store import users # tiny shared store — see below @bp.get("/") def home(): return render_template("home.html", count=len(users))
# myapp/blueprints/users/__init__.py from flask import Blueprint bp = Blueprint("users", __name__, url_prefix="/users") from . import routes # noqa: E402
# myapp/blueprints/users/store.py # In a real app this would be replaced by a SQLAlchemy model. users: dict[int, dict] = {} _next_uid = [1] def add_user(name: str) -> dict: uid = _next_uid[0] _next_uid[0] += 1 users[uid] = {"id": uid, "name": name} return users[uid]
# myapp/blueprints/users/routes.py from flask import render_template, request, redirect, url_for from . import bp from .store import users, add_user @bp.get("/") def list_users(): return render_template("users_list.html", users=users.values()) @bp.post("/") def create_user(): name = (request.form.get("name") or "").strip() if name: add_user(name) return redirect(url_for("users.list_users"))
<!-- myapp/templates/base.html -->
<!doctype html>
<html>
<head><title>{% block title %}MyApp{% endblock %}</title></head>
<body>
<nav>
<a href="{{ url_for('main.home') }}">Home</a> |
<a href="{{ url_for('users.list_users') }}">Users</a>
</nav>
<hr>
{% block content %}{% endblock %}
</body>
</html><!-- myapp/templates/home.html -->
{% extends "base.html" %}
{% block content %}
<h1>Home</h1>
<p>{{ count }} users registered.</p>
{% endblock %}<!-- myapp/templates/users_list.html -->
{% extends "base.html" %}
{% block content %}
<h1>Users</h1>
{% if users %}
<ul>{% for u in users %}<li>{{ u.name }}</li>{% endfor %}</ul>
{% else %}
<p>No users yet.</p>
{% endif %}
<form method="post" action="{{ url_for('users.create_user') }}">
<input name="name" placeholder="Name" required>
<button>Add</button>
</form>
{% endblock %}# wsgi.py — production entry point from myapp import create_app from myapp.config import ProductionConfig app = create_app(ProductionConfig)
Run development:
export FLASK_APP=myapp # PowerShell: $env:FLASK_APP="myapp" flask run
Run production:
gunicorn --workers 4 --bind 0.0.0.0:8000 wsgi:app # Windows: waitress-serve --listen=0.0.0.0:8000 wsgi:app
What this gets right:
- Factory pattern —
create_app(config_class). Tests passTestingConfig, production passesProductionConfig, dev uses the default. - Two blueprints, two namespaces.
main.homeandusers.list_userscan never collide. New features add a new blueprint folder, never edit a shared file. - Templates use the dotted form. Rename the blueprint and you fix
url_forcalls in one place. - Production secret comes from env.
ProductionConfigraises immediately on missingSECRET_KEY— better than running with a weak default. wsgi.pyfor gunicorn. Production servers point to a stable, importable WSGI app object.
What's missing for a real app:
- A real database — see flask-database for SQLAlchemy + Flask-Migrate.
usersas a dict is a stand-in. - Auth — auth-passwords, auth-jwt.
- Tests —
app.test_client()with aclientfixture, as shown in Section 8. - Dockerfile + CI — devops-docker, devops-github-actions.
- Logging, metrics, error tracking. The factory is where you wire those extensions up.
What You Learned
- Single-file Flask apps don't scale past a handful of routes. Move to the factory + blueprint layout early — refactoring later is more painful than starting that way.
- Blueprints are mini-Flask objects with their own routes, templates, and
url_prefix. Each owns one feature area (auth, billing, api, admin). - The application factory —
create_app(config_class)— builds a fresh app each call. Tests use a different config; production loads secrets from env vars. - Extensions (
db,migrate,login_manager) live inextensions.pyas module-level uninitialised objects.init_app(app)happens inside the factory. url_for("blueprint.view")with the dotted name. Inside a blueprint,url_for(".view")is a same-blueprint shortcut.gholds per-request state;current_appis the running app — both avoid circular imports.- Config classes (
Config,DevelopmentConfig,TestingConfig,ProductionConfig) keep environment differences in one file. Production fails loudly on missing secrets. app.test_client()with pytest gives sub-millisecond integration tests. CSRF off inTestingConfig. Fresh app per test.- Production: gunicorn (
workers = 2 × cores + 1) or waitress on Windows. nginx in front for TLS, static files, and connection buffering — or a managed platform that bundles those. - Never
app.run()in production. Single-threaded, no security hardening,debug=Trueis RCE.
You've now got the full Flask production stack. From here:
- JSON-first APIs — fastapi-basics is the modern alternative; faster, async-native, automatic OpenAPI docs.
- Batteries-included — django-basics for projects that want the admin, ORM, and auth in one box.
- Auth — auth-passwords, auth-jwt, auth-oauth.
- Production database — db-postgres, db-orm-patterns.
- Deployment — devops-docker, devops-github-actions, devops-environments, devops-secrets.