The last three lessons looked at Python as a client of the web. The next step is the other direction: writing a server. Flask is the smallest of Python's popular web frameworks: a few hundred lines of glue around a couple of older libraries, with no opinion about databases, templates, or project layout. A working web service fits in five lines of code. This lesson covers the Flask basics: defining routes, accepting form and JSON data, returning JSON responses, capturing URL parameters, the request and application contexts, and running Flask in development versus production.
Python has three main web frameworks. Django is the full-stack option, with an ORM, an admin panel, authentication, and a strong opinion about how an app is laid out. FastAPI is the modern async-first framework, designed around type hints and automatic API docs. Flask sits in the middle: small, sync, unopinionated, and very widely used.
| Framework | Style | Good Fit |
|---|---|---|
| Flask | Minimal, sync, unopinionated | Small services, microservices, scripts that grew up, internal tools |
| FastAPI | Async, typed, OpenAPI-first | Modern JSON APIs, performance-sensitive services |
| Django | Full-stack, "batteries included" | Full web apps with user accounts, admin panels, complex models |
Pick Flask for a small service with no ceremony: an API in front of a database, an internal tool, a webhook receiver, a prototype that might grow. The whole framework fits in memory easily. The trade-off is that things like auth, validation, and async support aren't built in; separate libraries fill those gaps when needed.
Install and write the smallest possible app:
Run it:
Then open http://127.0.0.1:5000/ in a browser, or call it from Python.
Three pieces are doing all the work. Flask(__name__) creates the application object. @app.route("/") registers home as the handler for GET /. The function returns a string, and Flask wraps it in a 200 response with Content-Type: text/html; charset=utf-8.
URLs map to functions through decorators. Whatever the function returns becomes the response. Everything else in this lesson is variations on that.
The diagram shows one request's path. Flask receives the request, the router picks the handler based on the URL pattern, the handler runs, and Flask turns the return value into an HTTP response.
@app.route(rule, methods=[...]) registers a function as the handler for a URL pattern. By default, the route only accepts GET. For other methods, pass methods=.
Three routes for two URLs. GET /products and POST /products go to different functions; that's how REST style maps onto Flask. GET /health and HEAD /health share a single handler.
The second return value from create_product is a tuple of (body, status_code). The handler can also return (body, status_code, headers). By default, the status code is 200.
Flask 2.0 added shortcut decorators for each method:
@app.get, @app.post, @app.put, @app.patch, @app.delete are sugar for @app.route(..., methods=["GET"]) and friends. Most modern Flask code uses them because they read more naturally.
Routes can include placeholders. Anything in <angle brackets> becomes a parameter the handler receives as a keyword argument.
A request to GET /products/101 runs get_product(product_id=101). The <int:product_id> syntax tells Flask to parse the value as an integer and pass it as product_id. Without the int: converter, the parameter would arrive as a string "101". If the URL doesn't fit the pattern (/products/abc with the int: converter), Flask returns 404 instead of calling the handler.
Built-in converters cover the common cases:
| Converter | Matches | Example |
|---|---|---|
string (default) | Any text without / | <name> |
int | Positive integers | <int:product_id> |
float | Floats with a . | <float:price> |
path | Like string but accepts / too | <path:subpath> |
uuid | UUID strings | <uuid:order_id> |
Multiple parameters work the same way.
URL parameters are for parts of the path that identify a specific resource. For optional filters, sorting, or paging, use query strings instead.
The request object holds everything about the incoming request: headers, query string, form data, JSON body, cookies, files. It's imported from flask and is automatically populated for the current request.
A call to GET /search?category=audio&max_price=100 returns a JSON object with the parsed parameters. request.args is a dict-like object of query parameters, and .get(key, type=...) converts on the way out.
request exposes several useful attributes:
| Attribute | Returns | Source |
|---|---|---|
request.method | str | "GET", "POST", etc. |
request.path | str | URL path (e.g. /products/101) |
request.args | MultiDict | Query string parameters |
request.headers | EnvironHeaders | Request headers |
request.cookies | dict | Cookies sent by the client |
request.form | MultiDict | Form-encoded POST/PUT body |
request.json | object or None | Parsed JSON body |
request.get_json(silent=True) | object or None | Same, with control over errors |
request.files | MultiDict | Files from a multipart upload |
request.remote_addr | str | Client IP (as Flask sees it) |
MultiDict is like a dict but allows multiple values per key. That's because the same query parameter can appear multiple times (?tag=sale&tag=new). Use args.get("tag") for the first value, or args.getlist("tag") for all of them.
For traditional form submissions (browser forms, older APIs), the body is form-encoded. Flask parses it into request.form.
Test it with requests:
The shape mirrors what the requests library lesson showed, from the server's perspective. The client sends data={...}, the server reads request.form, and they share the form-encoded format underneath.
For modern APIs, the body is JSON. request.json parses it automatically when the request's Content-Type is application/json.
Call it:
request.get_json(silent=True) returns the parsed body, or None if the body isn't valid JSON or the Content-Type is missing. With silent=False (the default), Flask raises an error and returns 400 on bad JSON. The silent=True form leaves control of the error message to the handler.
The handler validates the body (a step Flask itself doesn't do; that's why libraries like pydantic exist), builds the response, and returns a 201 with the new order. This is the shape of most JSON API endpoints in Flask.
Validating request bodies by hand gets tedious fast. For anything beyond two or three fields, libraries like pydantic (often via pydantic.BaseModel) or marshmallow provide type checking, required-field validation, and clean error messages with one line per endpoint.
Flask has two ways to return JSON. The first, since Flask 1.1, is to return a dict directly from the handler. Flask serialises it to JSON and sets the matching Content-Type.
A GET /products/101 returns:
Returning a list works the same way.
The second way is jsonify, useful for setting headers or a status code together with JSON.
jsonify returns a Response object that can be configured further. For simple cases, returning a dict is shorter. Use jsonify when the response object is needed explicitly.
Both forms set Content-Type: application/json automatically. The serialiser is json.dumps, so it has the same constraints (no sets, no datetime without help). Flask provides a JSONProvider extension point for customising the encoder when needed.
Returning a non-200 status is a matter of returning a tuple.
For redirects, use flask.redirect.
For aborting with a standard error response, use flask.abort.
abort(403) raises Forbidden, which Flask turns into a 403 response with a default error page. Pair it with custom error handlers (next section) to send JSON error bodies instead.
By default, Flask returns HTML error pages for 404, 500, and so on. For an API that should return JSON consistently, register handlers for the relevant codes.
Now any 404, whether from abort(404), an unmatched URL, or a returned tuple, gets a JSON body instead of HTML. Same for unhandled exceptions, which Flask turns into 500s.
A typical API also handles werkzeug.exceptions.HTTPException at the top level, which catches all 4xx and 5xx in one place.
This catches BadRequest, Unauthorized, Forbidden, NotFound, and the rest, and returns each one as JSON with the matching status. It's the smallest amount of code to make a Flask app consistently JSON-flavoured.
When Flask returns HTML, the standard tool is Jinja2, the templating engine Flask bundles by default. Templates live in a templates/ folder next to the app and use {{ ... }} for substitutions and {% ... %} for control flow.
A template at templates/product.html:
The handler renders it with render_template.
render_template reads the file, fills in the variables, and returns the resulting HTML as the response body. Templates are how Flask serves HTML pages for browsers.
For pure-JSON APIs, templates can be skipped entirely. They're useful when Flask is serving a small site or admin panel, or for generating HTML emails. Jinja2 is its own world with its own docs; this is enough to know it exists.
Flask uses two thread-local objects to pass data around: request (for everything about the current request) and g (for anything user code wants to attach to the current request).
before_request runs before every request; it stashes the start time on g. after_request runs before the response is sent; it reads the start time off g and adds a header. g is fresh on every request, so there's no leakage between calls.
g serves the same role in a real app: holding the current user object, a database connection, a request ID. Anything that's specific to one request but shared between layers of code.
The "application context" is similar but tied to the app rather than the request. It's where things like current_app and g (which is per-app-context, not per-request) live. Most code doesn't touch it directly; Flask sets it up automatically when handling a request.
The diagram shows the lifecycle of one request in Flask. The app context is pushed first, then the request context. Before-request hooks fire, then the handler, then after-request hooks. Then the contexts are popped and the response is sent. g lives inside this lifecycle; anything stored on it is gone after the response.
flask --app app run is the development server. It's fine for trying things out: it reloads when a file is saved, prints helpful tracebacks in the browser, and runs on 127.0.0.1:5000 by default.
The --debug flag turns on auto-reload and the in-browser debugger. Never run with --debug in production: the debugger is an open arbitrary-code-execution endpoint, by design (it's there to poke at variables during a crash).
For production, use a real WSGI server. WSGI (Web Server Gateway Interface) is the Python standard for how a web server talks to a Python web app. Flask implements the app side; servers like Gunicorn, uWSGI, and Waitress implement the server side.
Gunicorn is the most common in Linux production environments.
The arguments: 4 worker processes (each runs the Flask app independently), bound to port 8000 on all interfaces, and app:app means "module app, variable app inside it" (the Flask instance defined in code).
Why a separate server? The development server is single-threaded, doesn't handle concurrent requests well, and prioritises debuggability over throughput. A real WSGI server runs multiple workers, handles concurrent requests cleanly, and is much faster.
The development server can handle around 10-50 requests per second on a fast machine. Gunicorn with a few workers handles hundreds to thousands. The difference isn't a tuning gap; the dev server isn't meant for production.
A common production setup puts Nginx (or a similar reverse proxy) in front of Gunicorn. Nginx handles TLS termination, static files, and load balancing across multiple Gunicorn instances. Gunicorn handles the Python side. The Flask app code doesn't change between development and production; only the runner does.
A real app has many routes. Keeping them all in one app.py doesn't scale beyond a few dozen endpoints. Blueprints group related routes and register them with the app.
Blueprint behaves like a mini-app with its own routes, templates, and error handlers. Registering it prefixes all its routes and attaches them to the main app. GET /products/ and GET /products/101 work as if they were defined on app directly.
Blueprints split a Flask app into modules: one blueprint per resource (products, orders, customers), or one per feature area (api, admin, public). For an app with a few endpoints, they aren't needed. For anything larger, they're the standard way to keep routes organised.
Flask is well-suited to:
Pick something else when:
Flask sits at the small end of the framework spectrum. The framework itself is a tiny layer; everything else (database, auth, validation, async) is an added library. That makes Flask flexible, but it also means a complete production setup involves wiring several libraries together.
10 quizzes