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 Flask instance 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.pyDevelopmentConfig, ProductionConfig classes; secrets from environment.
  • app/models.py or app/models/ — SQLAlchemy model classes.
  • app/routes/ or feature blueprints — fleet.py, auth.py.
  • app/templates/ — Jinja2 HTML; app/static/ for CSS/JS.
  • wsgi.pyapplication = 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; check filename and 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.fixture returns create_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 db or current_app outside a context raises RuntimeError.
  • Default development server in productionflask run is 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_KEY and 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-tools or 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