Guide
Flask fundamentals explained
A fleet operator opens a browser tab and sees live vessel positions on a map. Behind that page, a small Python service receives GPS webhooks, stores the latest coordinates in PostgreSQL, and serves both an HTML dashboard and a JSON API from the same codebase. That service is a typical Flask application: a few hundred lines of routing logic, Jinja2 templates for the UI, SQLAlchemy for persistence, and no framework-imposed project layout beyond what the team chooses. Flask calls itself a microframework because its core is deliberately tiny — routing, request/response objects, and a template engine hook. Auth, ORM, migrations, admin panels, and async I/O arrive through extensions or your own modules. That minimalism made Flask the default Python choice for APIs, internal tools, and MVPs for over a decade; it still fits when you want Python ergonomics without Django’s full stack or FastAPI’s async-first contract. This guide covers the WSGI request lifecycle, the application factory pattern, routing and blueprints, Jinja2 templates and request context, the extensions ecosystem, configuration and error handling, testing with pytest, a Harbor Fleet status API worked example, a framework decision table, common pitfalls, and a production checklist.
What Flask is and how requests flow
Flask is a web framework for
Python built
on Werkzeug (HTTP utilities, routing, WSGI) and
Jinja2 (templating). You create an application object
(Flask(__name__)), decorate functions with
@app.route, and run the development server with
flask run or deploy behind Gunicorn, uWSGI, or Waitress in
production. Flask 3.x targets modern Python (3.9+) and tightens type hints;
check release notes when upgrading legacy 1.x/2.x projects.
A WSGI request enters your app callable, Flask matches the URL against
registered rules, pushes an application context and a
request context onto thread-local stacks, runs any
before_request hooks, executes your view function, runs
after_request hooks, and returns a Response object.
View functions return strings (wrapped as HTML responses), tuples
(body, status), Response instances, or dicts (JSON
when using Flask 2.2+ JSON helpers). Exceptions bubble to registered error
handlers (@app.errorhandler(404)).
Core concepts
- Application — the central
Flaskinstance holding config, routes, and extensions. - View function — a Python callable mapped to a URL rule and HTTP methods.
- Blueprint — a reusable route bundle you register on the app (like Django apps or Express Routers).
- Extension — third-party package initialized with
ext.init_app(app)(SQLAlchemy, Migrate, Login). - g — request-global namespace for per-request data (current user, DB session shortcuts).
- current_app / request — proxies to the active app and request inside a context.
Application factory and project layout
The application factory pattern creates your
Flask instance inside a function rather than at import time:
def create_app(config_name): app = Flask(__name__); app.config.from_object(...); register_blueprints(app); return app.
Factories enable multiple app instances in tests (each with its own config),
lazy extension initialization, and Celery worker processes that need an app
context without running the web server.
A maintainable layout separates concerns:
app/__init__.py— factory, extension instances, blueprint registration.app/config.py—DevelopmentConfig,ProductionConfigclasses; secrets from environment.app/models.pyorapp/models/— SQLAlchemy model classes.app/routes/or feature blueprints —fleet.py,auth.py.app/templates/— Jinja2 HTML;app/static/for CSS/JS.wsgi.py—application = create_app(os.getenv('FLASK_CONFIG'))for Gunicorn.
Avoid a single 2,000-line app.py. Flask does not enforce structure
— that freedom is the main long-term risk. Adopt conventions early and
document them in a short README.
Routing, blueprints, and request data
Routes map URL patterns and HTTP verbs to view functions. Variable segments use
converters: <int:vessel_id>, <uuid:token>,
<path:filepath>. Method restrictions:
@app.route('/ships', methods=['GET', 'POST']). Use
url_for('fleet.vessel_detail', vessel_id=42) in templates instead
of hardcoded paths so blueprint renames do not break links.
Blueprints group related routes:
fleet_bp = Blueprint('fleet', __name__, url_prefix='/fleet'), then
app.register_blueprint(fleet_bp). Large teams split ownership by
blueprint; each can carry its own templates folder via
template_folder.
Accessing request data
request.args— query string (ImmutableMultiDict).request.form— POST form fields (requires correct Content-Type).request.json— parsed JSON body (Flask 2.3+); validate before trusting.request.files— uploaded files; checkfilenameand size limits.request.headers— auth tokens, correlation IDs, content negotiation.
Never trust client input. Pair Flask with Marshmallow,
Pydantic (via manual validation), or WTForms
for server-side validation. Return 400 with structured errors for
API clients; re-render forms with field errors for HTML flows.
Templates, static files, and sessions
Jinja2 renders HTML from templates in templates/.
Layout inheritance uses {% extends "base.html" %} and
{% block content %}. Auto-escaping is enabled for HTML, which
mitigates XSS when you display user-generated strings. Filters
({{ price | round(2) }}) and macros keep templates thin; heavy
logic belongs in view functions or template context processors.
render_template('fleet/map.html', vessels=vessels) passes context
variables. url_for('static', filename='css/map.css') generates
static asset URLs. In production, serve static files from nginx or a CDN;
Flask’s built-in static handler is for development only.
Flask’s signed cookie sessions store small server-side
opaque data client-side (session ID, flash messages). Set
SECRET_KEY to a long random value and never commit it. For
server-side sessions at scale, use Flask-Session with Redis. Enable
SESSION_COOKIE_SECURE and HTTPONLY in production.
Flash messages (flash('Saved.')) survive one redirect — useful
for POST-redirect-GET form patterns.
Extensions: database, auth, and APIs
Flask’s ecosystem fills gaps through extensions initialized in the factory:
- Flask-SQLAlchemy — ORM session bound to the app; models inherit
db.Model. - Flask-Migrate — Alembic migrations wrapped for Flask CLI (
flask db migrate). - Flask-Login — session-based user authentication with
@login_required. - Flask-WTF — WTForms integration with CSRF tokens on POST forms.
- Flask-RESTful / flask-smorest — class-based API resources with optional OpenAPI.
- Flask-CORS — cross-origin headers for browser API consumers.
- Flask-Limiter — rate limiting by IP or API key.
SQLAlchemy 2.0 style works with Flask-SQLAlchemy 3.x: declarative models,
db.session.execute(select(Vessel).where(...)), and explicit
transactions. Use connection pooling appropriate to your worker count; a common
production mistake is Gunicorn workers × pool size exceeding PostgreSQL
max_connections.
For JSON-only microservices, many teams now choose FastAPI for native async and automatic OpenAPI. Flask remains strong when you need Jinja2 pages, gradual migration from scripts, or a team already fluent in its patterns.
Configuration, logging, and error handling
Store config in classes or environment variables — never hardcode
database URLs. app.config.from_prefixed_env() (Flask 2.1+) maps
FLASK_DATABASE_URI to DATABASE_URI. Use
python-dotenv locally; inject secrets via your orchestrator in
production.
Register error handlers per status code or exception type. Return JSON for API
blueprints and HTML error pages for browser routes — check
request.accept_mimetypes or split blueprints by client type.
Log exceptions with Python’s logging module or
structlog; attach request IDs in before_request
from an incoming X-Request-ID header or a generated UUID.
Flask is synchronous WSGI by default. CPU-bound or blocking I/O heavy
workloads block a worker thread. For high-concurrency I/O, consider FastAPI
async, gevent workers with monkey-patching (know the caveats), or offload slow
work to Celery/RQ task queues and return 202 Accepted with a job ID.
Testing with pytest and the test client
The test client simulates HTTP without binding a port:
client = app.test_client(); response = client.post('/fleet/webhook', json=payload).
Use an application factory with TestingConfig (in-memory SQLite,
TESTING = True, disable CSRF for unit tests that skip forms).
pytest fixtures create a fresh app per test:
@pytest.fixturereturnscreate_app('testing').with app.app_context():for database setup and model queries.with app.test_client() as client:for HTTP assertions.- Factory Boy or custom fixtures for test data; truncate tables between tests.
Test critical paths: auth gates, webhook signature verification, idempotent POST handlers, and pagination boundaries. Integration tests against a real PostgreSQL container catch dialect-specific bugs SQLite hides.
Worked example: Harbor Fleet status API
Harbor Fleet operates twelve coastal delivery vessels. Operations needs a lightweight service that ingests GPS pings from onboard trackers, shows a live map for dispatchers, and exposes JSON for partner logistics software — without adopting a full Django stack.
Architecture
A Flask factory registers two blueprints: fleet_web (Jinja2 map
page) and fleet_api (JSON under /api/v1). Extensions:
Flask-SQLAlchemy, Flask-Migrate, Flask-Limiter. PostgreSQL stores
Vessel (name, IMO number) and Position (lat, lon,
recorded_at, vessel FK). Redis caches the latest position per vessel for
sub-100ms map polling.
Webhook ingestion
Trackers POST to /api/v1/positions with an HMAC signature header.
A before_request hook on the API blueprint verifies the signature
against a shared secret before JSON parsing. The view validates payload with a
Pydantic model, upserts Position in a transaction, writes through
to Redis, and returns 201. Duplicate timestamps from the same
device return 200 idempotently.
Dashboard and partner API
/fleet/map renders Leaflet.js with vessels fetched from Redis.
GET /api/v1/vessels returns GeoJSON for partners; rate-limited to
60 requests per minute per API key. Flask-Login guards the map route; partners
use Bearer tokens checked in a decorator.
Deployed with Gunicorn (4 sync workers), nginx TLS termination, and
flask db upgrade in the release pipeline. Health check at
/health verifies database connectivity. Structured JSON logs ship
to the Harbor observability stack.
Framework decision table
| Need | Prefer | Why |
|---|---|---|
| Full-stack monolith with admin and ORM | Django | Batteries included; conventions reduce glue code |
| Small API + HTML pages, team knows Flask | Flask | Minimal core; pick extensions à la carte |
| Typed async JSON API, auto OpenAPI | FastAPI | Native async; Pydantic validation built in |
| Internal tool in a day | Flask | Fastest path from script to routed HTTP |
| High-concurrency WebSockets + HTTP | FastAPI / Starlette | ASGI first-class; Flask needs workarounds |
| Strict project structure enforced | Django | Apps, settings, migrations are standardized |
Common pitfalls
- Global app at import time — breaks tests and CLI tools; use a factory.
- Missing app/request context — accessing
dborcurrent_appoutside a context raisesRuntimeError. - Default development server in production —
flask runis not hardened; use Gunicorn behind nginx. - Weak or committed SECRET_KEY — forges session cookies; rotate on leak.
- N+1 queries in templates — eager-load relationships before
render_template. - Blocking the worker — long external API calls without timeouts stall all requests on that worker.
- CSRF omitted on forms — use Flask-WTF or manual CSRF tokens on every mutating POST.
- Extension init order — initialize extensions on the app before registering blueprints that import models.
Production checklist
- Application factory with environment-specific config classes.
- Gunicorn or uWSGI with worker count tuned to CPU and DB pool limits.
- nginx (or similar) for TLS, static files, and request size limits.
SECRET_KEYand database credentials from environment only.- Flask-Migrate migrations applied in CI/CD before traffic shift.
- Structured logging with request IDs; error tracking (Sentry) on 5xx.
- Rate limits on public and webhook endpoints; HMAC or API keys verified.
- Health and readiness probes; graceful shutdown on
SIGTERM. - pytest suite covering auth, webhooks, and critical API contracts.
- Dependency pinning (
pip-toolsor Poetry lockfile); regular audits.
Key takeaways
- Flask is routing plus contexts plus Jinja2 — everything else is your choice or an extension.
- Application factories and blueprints keep projects testable as they grow past a prototype.
- Pair Flask with SQLAlchemy, Migrate, and pytest for a credible production stack.
- Choose Flask when you want lean control; reach for Django or FastAPI when their defaults fit better.
- Never ship with the dev server, a default secret key, or unvalidated webhook bodies.
Related reading
- Python fundamentals explained — language basics under Flask views and models
- Django fundamentals explained — batteries-included alternative for full-stack Python
- FastAPI fundamentals explained — async Python APIs with automatic OpenAPI
- REST API design explained — resources, status codes, and error shapes for Flask APIs