
Flask is a lightweight Python web framework built around WSGI. It's great for REST APIs, microservices, internal tools, and quick prototypes.
What is WSGI?
Flask follows the WSGI standard (Web Server Gateway Interface). Think of WSGI as a “plug shape” that lets a Python app (Flask) connect to a production web server (like Gunicorn/uWSGI), which then connects to the internet (often through Nginx).
Introduction
Flask is a lightweight Python web framework built around WSGI.
It's great for REST APIs, microservices, internal tools, and quick prototypes.
Flask shines when you want:
- Minimal framework "opinions"
- Full control over project structure
- A small and explicit dependency footprint
- An easy path from prototype → production (with Gunicorn + reverse proxy)
Core Concepts
Here are the key Flask ideas you’ll use in almost every project.
Request lifecycle
When a request hits your app:
- Flask matches the URL + HTTP method to a route
- Flask builds a
requestobject (headers, query string, body, etc.) - Your route function runs
- You return data (string / dict /
Response) - Flask converts it into an HTTP response
HTTP methods
- GET → read data (safe, shouldn’t change server state)
- POST → create something new
- PUT → update/replace an existing resource
- PATCH → partial update
- DELETE → remove
Routing
Routes map URLs to Python functions.
Tip: A route is defined by (method + path). You can reuse a path with different methods.
Example: GET /api/items vs POST /api/items.
Path parameters: Anything in angle brackets becomes a variable:
/api/items/<item_id>→ your function receivesitem_id
Query parameters: Appear after ? and are read via request.args:
/api/items?limit=10→request.args.get('limit')
from flask import Flask
app = Flask(__name__)
@app.route("/hello")
def hello():
return "Hello from Flask!"
Request + JSON parsing
from flask import request, jsonify
@app.route("/echo", methods=["POST"])
def echo():
data = request.get_json(silent=True) or {}
if "name" not in data:
return jsonify({"error": "Field 'name' is required"}), 400
return jsonify(data)
Why silent=True? If the request body is missing or invalid JSON, Flask won’t throw an exception. You can safely handle it and return a clean 400. The example above also validates that the name field is present; if it's missing, the endpoint returns 400 Bad Request with a JSON error message.
If the body of your request is JSON, send the header:
Content-Type: application/jsonNote that all curl examples below that send a JSON body include this header.
Response code examples
Return a second value from your route to set the HTTP status code. Use codes that match the outcome so clients and tools can react correctly.
201 Created - after creating a resource:
from flask import jsonify
@app.route("/created", methods=["POST"])
def created():
return jsonify({"ok": True}), 201
400 Bad Request - when the request body is invalid or required fields are missing:
@app.route("/items", methods=["POST"])
def create_item():
data = request.get_json(silent=True) or {}
if "name" not in data:
return jsonify({"error": "Field 'name' is required"}), 400
# ... create item
return jsonify({"id": "123", "name": data["name"]}), 201
404 Not Found - when a resource does not exist:
@app.route("/items/<item_id>", methods=["GET"])
def get_item(item_id):
item = find_item_by_id(item_id)
if item is None:
return jsonify({"error": "Item not found"}), 404
return jsonify(item)
204 No Content - success with no body (e.g. after delete):
@app.route("/items/<item_id>", methods=["DELETE"])
def delete_item(item_id):
if not remove_item(item_id):
return jsonify({"error": "Item not found"}), 404
return "", 204
Real-life scenario: a short story
Imagine a small internal tool your team uses to track tasks or inventory.
- Is the backend up? The frontend calls
GET /api/healthbefore showing the app. - Add a task. The app sends
POST /api/itemswith a name and gets back the new item and its ID. - Read a task. Load it with
GET /api/items/<id>. - Edit the name. Send
PUT /api/items/<id>with the updated data.
That’s exactly the kind of API we’ll build in the next section: health check plus create, get, update, and more for a simple list of items.
Common REST design rules
- Use nouns in paths:
/items, not/getItem - Use HTTP methods to express action: GET/POST/PUT
- Return consistent JSON shapes (either
{...}or{error: ...}) - Use correct status codes:
201for created,404for missing
Practical mini REST API (6 endpoints)
Install Flask
Use uv for a fast, reliable setup (install from astral.sh if needed). uv prefers a project with pyproject.toml over requirements.txt; see uv Features. Create a project and add Flask:
uv init flask_app
cd flask_app
uv add flask
This creates a virtual environment (.venv), a pyproject.toml with the dependency, and a lockfile. Run the app with uv run python app.py or uv run flask run (no need to activate the venv).
Minimal project structure
flask_app/
├─ app.py
├─ pyproject.toml
└─ uv.lock
pyproject.toml
uv generates this when you run uv add flask. It declares the project and dependencies:
[project]
name = "flask_app"
version = "0.1.0"
description = ""
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"flask>=3.0.0",
]
Use uv sync to install (or reinstall) from the lockfile. You can pin exact versions in pyproject.toml or rely on uv.lock for reproducible builds.
Source code: app.py (6 endpoints)
This is deliberately "small but real": validation, timestamps, IDs, and consistent JSON responses.
from flask import Flask, request, jsonify
from datetime import datetime, timezone
import uuid
app = Flask(__name__)
# In-memory store for demo purposes only
fake_db: dict[str, dict] = {
"a1b2c3d4-0000-4000-8000-000000000001": {
"id": "a1b2c3d4-0000-4000-8000-000000000001",
"name": "First item",
"created_at": "2025-01-15T10:00:00+00:00",
"updated_at": "2025-01-15T10:00:00+00:00",
},
"a1b2c3d4-0000-4000-8000-000000000002": {
"id": "a1b2c3d4-0000-4000-8000-000000000002",
"name": "Second item",
"created_at": "2025-01-15T10:00:00+00:00",
"updated_at": "2025-01-15T10:00:00+00:00",
},
}
def utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
# 1) Health check
@app.route("/api/health", methods=["GET"])
def health():
return jsonify({
"status": "ok",
"timestamp": utc_now_iso(),
})
# 2) Create item
@app.route("/api/items", methods=["POST"])
def create_item():
data = request.get_json(silent=True) or {}
name = data.get("name")
if not name or not isinstance(name, str):
return jsonify({"error": "Field 'name' (string) is required"}), 400
item_id = str(uuid.uuid4())
item = {
"id": item_id,
"name": name.strip(),
"created_at": utc_now_iso(),
"updated_at": utc_now_iso(),
}
fake_db[item_id] = item
return jsonify(item), 201
# 3) Get item
@app.route("/api/items/<item_id>", methods=["GET"])
def get_item(item_id: str):
item = fake_db.get(item_id)
if not item:
return jsonify({"error": "Item not found"}), 404
return jsonify(item)
# 4) Update item
@app.route("/api/items/<item_id>", methods=["PUT"])
def update_item(item_id: str):
item = fake_db.get(item_id)
if not item:
return jsonify({"error": "Item not found"}), 404
data = request.get_json(silent=True) or {}
name = data.get("name")
if name is not None:
if not isinstance(name, str) or not name.strip():
return jsonify({"error": "If provided, 'name' must be a non-empty string"}), 400
item["name"] = name.strip()
item["updated_at"] = utc_now_iso()
return jsonify(item)
# 5) Partial update (PATCH)
@app.route("/api/items/<item_id>", methods=["PATCH"])
def patch_item(item_id: str):
item = fake_db.get(item_id)
if not item:
return jsonify({"error": "Item not found"}), 404
data = request.get_json(silent=True) or {}
if not data:
return jsonify({"error": "PATCH body must contain at least one field to update"}), 400
name = data.get("name")
if name is not None:
if not isinstance(name, str) or not name.strip():
return jsonify({"error": "If provided, 'name' must be a non-empty string"}), 400
item["name"] = name.strip()
item["updated_at"] = utc_now_iso()
return jsonify(item)
# 6) Delete item
@app.route("/api/items/<item_id>", methods=["DELETE"])
def delete_item(item_id: str):
if item_id not in fake_db:
return jsonify({"error": "Item not found"}), 404
del fake_db[item_id]
return "", 204
if __name__ == "__main__":
# Debug server for local dev only
app.run(host="127.0.0.1", port=5000, debug=True)
How the 6 endpoints work (step-by-step)
1) GET /api/health
What it does: returns a small JSON payload so you can quickly confirm the server is alive.
Why include timestamp?
- Helps debugging (you know the response is fresh)
- Useful for monitoring and incident logs
2) POST /api/items
What it expects: JSON like { "name": "First item" }
What happens inside:
request.get_json(silent=True)tries to parse JSON- We validate
nameis a string - Generate a UUID for a stable ID
- Store the item (in-memory for demo)
- Return
201with the new item JSON
3) GET /api/items/<item_id>
What it does: reads the path parameter item_id and looks it up.
Why return 404?
- Missing resource is not a “success with empty object”
- Frontend can show “Not found” correctly
4) PUT /api/items/<item_id>
What it does: updates an existing item.
Why allow partial update here?
- This example treats PUT as “update provided fields”.
- In strict REST, PUT often means “replace the whole resource”.
- For partial updates you can also use
PATCH. The important part is: validate inputs and return consistent JSON.
5) PATCH /api/items/<item_id>
What it does: partially updates an existing item — only the fields you send in the JSON body are changed.
How it differs from PUT:
- PUT (in this demo) can update one or more fields; in strict REST it means "replace the whole resource".
- PATCH is explicitly "partial update": you send only
{"name": "New name"}and nothing else on the item is touched.
What happens inside:
- Look up the item; return
404if missing. - Require at least one field in the body (empty
{}→400). - If
nameis present, validate and update it. - Refresh
updated_atand return the updated item.
6) DELETE /api/items/<item_id>
What it does: removes the item from the store.
Why return 204?
- 204 No Content is the standard success response for "request succeeded and there is no body to return".
- The client knows the delete worked without having to parse a response body.
What happens inside:
- Check if the item exists; return
404if not. - Remove it from
fake_db. - Return empty body with status
204.
Try it: curl examples
curl http://127.0.0.1:5000/api/health
curl -X POST http://127.0.0.1:5000/api/items \
-H "Content-Type: application/json" \
-d '{"name":"First item"}'
curl http://127.0.0.1:5000/api/items/<ITEM_ID>
curl -X PUT http://127.0.0.1:5000/api/items/<ITEM_ID> \
-H "Content-Type: application/json" \
-d '{"name":"Renamed"}'
curl -X PATCH http://127.0.0.1:5000/api/items/<ITEM_ID> \
-H "Content-Type: application/json" \
-d '{"name":"Patched name"}'
curl -X DELETE http://127.0.0.1:5000/api/items/<ITEM_ID>
Common error tests (recommended)
curl -X POST http://127.0.0.1:5000/api/items -d '{"name":"x"}'
curl -X POST http://127.0.0.1:5000/api/items \
-H "Content-Type: application/json" \
-d '{}'
curl http://127.0.0.1:5000/api/items/does-not-exist
curl -X PATCH http://127.0.0.1:5000/api/items/<ITEM_ID> \
-H "Content-Type: application/json" \
-d '{}'
curl -X DELETE http://127.0.0.1:5000/api/items/does-not-exist
Endpoint summary
[
{ "method": "GET", "path": "/api/health", "purpose": "Readiness/health check" },
{ "method": "POST", "path": "/api/items", "purpose": "Create item" },
{ "method": "GET", "path": "/api/items/<id>", "purpose": "Fetch item" },
{ "method": "PUT", "path": "/api/items/<id>", "purpose": "Update item" },
{ "method": "PATCH", "path": "/api/items/<id>", "purpose": "Partial update" },
{ "method": "DELETE", "path": "/api/items/<id>", "purpose": "Delete item" }
]
Production considerations (quick checklist)
Flask's built-in server is for development. For production:
Flask itself is production-capable — you just run it behind a proper WSGI server.
- Run with Gunicorn
- Put Nginx in front (reverse proxy, TLS, caching)
- Add structured logging
- Use a real DB (PostgreSQL, MySQL, etc.)
- Add auth (JWT), rate limiting, CORS rules
- Configure environment variables (secrets)
- Consider Blueprints when the app grows (split routes by feature)
- Add error handlers for consistent
{error: ...}responses - Add CORS rules if you call the API from a browser SPA (React, Angular, Vue, ...)
- Add timeouts + request size limits on Nginx
- Add tests (pytest) for endpoints and validation
- Add DB migrations (Alembic/Flask-Migrate) when you move to a real DB
uv add gunicorn
uv run gunicorn -w 4 -b 0.0.0.0:8000 app:app
What to learn next
- Blueprints (organize routes)
- Config (
app.config+ environment variables) - Database (SQLAlchemy) + migrations
- Auth (JWT / session) and permissions
- Testing (pytest) + CI
- Observability (structured logs, metrics)
External references
- Flask docs
- Werkzeug (Flask core dependency)
- Gunicorn
