What is Flask?
Flask is a minimal web framework for Python. It gives you an application object, a way to map URLs to functions, and a request and response model. Everything else — databases, authentication, forms, background jobs — is a decision you make later, usually by adding an extension.
That smallness is the whole point. Django has an opinion about how a project should be structured; Flask has almost none. For a small API, an internal tool or a first web project, that freedom is a feature. You can read the entire framework in an afternoon and understand every line of your own app.
Flask is built on two long-standing libraries: Werkzeug for the WSGI plumbing and Jinja2 for templates. Both are mature and well documented, and knowing that they are underneath explains most of Flask’s behaviour.
The smallest Flask app
A complete Flask application can fit in a few lines.
# app.py
from flask import Flask
app = Flask(__name__)
@app.get("/")
def index():
return "Hello, world"
if __name__ == "__main__":
app.run(debug=True)
Flask(__name__) creates the application and tells it where to find templates and static files. The decorator binds a URL to the function below it. app.run() starts the development server. The if __name__ == "__main__" guard keeps the server from starting when the module is imported by a test or a WSGI server.
Routing with decorators
The @app.route decorator is the heart of Flask. It registers a URL rule and the function that answers it.
@app.route("/posts")
def list_posts():
return "All posts"
@app.route("/posts/<int:post_id>")
def get_post(post_id):
return f"Post {post_id}"
Rules can contain variable parts written in angle brackets. Flask matches the URL, converts the value and passes it to the function as a keyword argument. If no rule matches, Flask returns a 404 automatically.
URL variables and HTTP methods
Converters make variables type-safe before your code runs. The common ones are string (the default), int, float, path (which allows slashes) and uuid.
@app.get("/users/<username>")
def profile(username):
return f"Profile for {username}"
@app.get("/files/<path:filepath>")
def serve_file(filepath):
return f"File at {filepath}"
Flask 2.0 added shortcuts for each method. @app.get, @app.post, @app.put, @app.patch and @app.delete are clearer than passing a methods list, and a single function can answer several methods.
@app.route("/posts", methods=["GET", "POST"])
def posts():
if request.method == "POST":
return create_post()
return list_posts()
The request object
Incoming data lives on the global request object, which Flask populates for the current request. It is a proxy, so importing it once at the top of the module is safe.
from flask import request
@app.get("/search")
def search():
query = request.args.get("q", "")
page = request.args.get("page", 1, type=int)
return {"query": query, "page": page}
@app.post("/posts")
def create_post():
payload = request.get_json()
title = payload["title"]
return {"title": title}, 201
request.args holds query-string values, request.form holds form posts, request.get_json() parses a JSON body, request.files holds uploads, and request.headers gives the raw headers. Because these are multi-value mappings, use .get() and provide defaults rather than indexing blindly.
Responses and JSON
A view can return a string, a dictionary (which Flask serialises to JSON), a tuple of (body, status) or (body, status, headers), or a full Response object.
from flask import jsonify, make_response
@app.post("/posts")
def create_post():
post = {"id": 1, "title": "Hello"}
return jsonify(post), 201
@app.get("/ping")
def ping():
response = make_response({"pong": True})
response.headers["Cache-Control"] = "no-store"
return response
jsonify is the explicit way to return JSON: it serialises the data and sets the content type. Returning a dictionary works too, but jsonify is clearer about intent and handles more types.
Templates with Jinja2
render_template loads a file from the templates/ folder and renders it with the data you pass. Jinja2 escapes variables by default, which protects against XSS.
from flask import render_template
@app.get("/posts/<int:post_id>")
def get_post(post_id):
post = {"id": post_id, "title": "Hello", "body": "First post"}
return render_template("post.html", post=post)
<!-- templates/post.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>{{ post.title }}</title>
</head>
<body>
<h1>{{ post.title }}</h1>
<p>{{ post.body }}</p>
</body>
</html>
Jinja supports {% extends %} for inheritance, {% include %} for fragments, {% for %} and {% if %} for logic, and filters such as {{ value|upper }}. Keep templates presentational; if the logic grows, move it into the view.
Static files
Flask serves anything in the static/ folder at /static/... by default. Reference it with url_for, which builds the URL from the endpoint name.
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
In production, let a reverse proxy or CDN serve static files instead of the Python process. It is faster and frees the app to handle requests.
Blueprints
A Blueprint is a collection of routes and templates that can be registered on an app. It is how a single-module Flask app grows into a project without becoming a mess.
# blog/views.py
from flask import Blueprint, jsonify
bp = Blueprint("blog", __name__, url_prefix="/blog")
@bp.get("/")
def index():
return jsonify(posts=[])
@bp.get("/<int:post_id>")
def detail(post_id):
return jsonify(id=post_id)
# app.py
from blog.views import bp as blog_bp
app.register_blueprint(blog_bp)
Blueprints carry their own URL prefix, templates and static files, and one blueprint can be registered multiple times with different prefixes. They also make testing and code review easier because each feature is self-contained.
The application factory
Once you use blueprints, the natural next step is a factory function that builds the app. This is the standard structure for anything larger than a demo.
# app.py
from flask import Flask
from blog.views import bp as blog_bp
def create_app(config_object="config.Config"):
app = Flask(__name__)
app.config.from_object(config_object)
app.register_blueprint(blog_bp)
return app
# wsgi.py
from app import create_app
app = create_app()
The factory makes tests trivial: build an app with a test configuration, run the client, and throw it away. It also avoids importing a half-configured global app, which is the root of most circular-import problems in Flask.
Extensions
Because the core is minimal, the ecosystem provides the rest. A few extensions cover most projects:
- Flask-SQLAlchemy integrates SQLAlchemy’s ORM and manages the session per request.
- Flask-Migrate wraps Alembic so schema changes are versioned.
- Flask-Login handles user sessions and the
current_userproxy. - Flask-WTF adds forms, CSRF protection and file uploads.
- Flask-CORS controls cross-origin requests for APIs.
# extensions.py
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
db = SQLAlchemy()
migrate = Migrate()
# app.py
from extensions import db, migrate
def create_app():
app = Flask(__name__)
app.config.from_object("config.Config")
db.init_app(app)
migrate.init_app(app, db)
return app
The init_app pattern lets an extension be created without an app and bound later, which keeps the factory clean and the extension importable by models.
Configuration
Configuration is a plain mapping on app.config. Load it from a class, a file or the environment, and keep secrets out of source control.
# config.py
import os
class Config:
SECRET_KEY = os.environ["SECRET_KEY"]
SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"]
JSON_SORT_KEYS = False
app.config.from_object("config.Config")
app.config.from_prefixed_env()
from_prefixed_env() reads FLASK_* environment variables, which is convenient in containers. Use different classes or files per environment, and never commit the real SECRET_KEY — it signs sessions and CSRF tokens.
Error handling
Register error handlers to return consistent responses for the whole app. Handlers can be global or scoped to a blueprint.
from flask import jsonify
from werkzeug.exceptions import HTTPException
@app.errorhandler(404)
def not_found(error):
return jsonify(error="not_found"), 404
@app.errorhandler(Exception)
def unhandled(error):
if isinstance(error, HTTPException):
return error
app.logger.exception(error)
return jsonify(error="internal_error"), 500
The handler for HTTPException is important: Flask’s built-in errors are exceptions too, and a blanket Exception handler would otherwise turn a 404 into a 500.
Testing
Flask’s test client makes requests in-process, with no server and no network.
import pytest
from app import create_app
@pytest.fixture
def client():
app = create_app("config.TestConfig")
with app.test_client() as client:
yield client
def test_health(client):
response = client.get("/health")
assert response.status_code == 200
assert response.get_json() == {"status": "ok"}
def test_missing_post(client):
response = client.get("/posts/999")
assert response.status_code == 404
The factory and test_client() work together: each test gets a fresh app, so state cannot leak between tests. Override configuration for tests and use an in-memory database when you add one.
WSGI and deployment
Flask is a WSGI application, which means any WSGI server can run it. The development server is for development only.
pip install gunicorn
gunicorn --workers 4 --bind 0.0.0.0:8000 "app:create_app()"
In production, run Gunicorn or uWSGI behind Nginx, terminate TLS at the proxy, and serve static files from the proxy or a CDN. Set DEBUG=False, configure logging, and read secrets from the environment. Containerising the app makes the deployment portable across hosts.
Best practices
- Start with a single module, then move to a factory and blueprints as it grows.
- Use
url_forto build URLs instead of hardcoding paths. - Keep configuration in classes or environment variables, never in code.
- Use
jsonifyand explicit status codes for API responses. - Validate every piece of input before it reaches your logic.
- Register error handlers so failures return a consistent shape.
- Use the test client and a fresh app per test.
- Never run the development server in production.
Common mistakes
- Leaving
debug=Trueon in production, which exposes an interactive debugger. - Importing a global
appeverywhere and creating circular imports. - Reading
request.jsonwithout handling malformed or missing bodies. - Hardcoding URLs in templates instead of using
url_for. - Building a monolithic
app.pywith hundreds of routes. - Forgetting CSRF protection when using forms.
- Storing secrets in
config.pyand committing it. - Assuming Flask has an ORM or auth built in, then reinventing both badly.
Where to go next
Flask is the fastest way to learn what a web framework actually does, because so little is hidden. When you want an ORM, migrations, auth and an admin without assembling them, read the Django guide. When you want automatic validation and API docs from type hints, move to FastAPI. And to design the endpoints you just built, the REST APIs guide is the natural companion. If you add a database, the backend roadmap covers SQL, the language underneath the ORM.