Dusan Kovacevic
Dusan Kovacevic
25. Feb 2025 - 8 min read

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:

  1. Flask matches the URL + HTTP method to a route
  2. Flask builds a request object (headers, query string, body, etc.)
  3. Your route function runs
  4. You return data (string / dict / Response)
  5. 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 receives item_id

Query parameters: Appear after ? and are read via request.args:

  • /api/items?limit=10request.args.get('limit')
example1.py
from flask import Flask

app = Flask(__name__)

@app.route("/hello")
def hello():
    return "Hello from Flask!"

Request + JSON parsing

example2.py
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/json

Note 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:

example3.py
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:

example4.py
@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:

example5.py
@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):

example6.py
@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/health before showing the app.
  • Add a task. The app sends POST /api/items with 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: 201 for created, 404 for 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:

pyproject.toml
[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.

app.py
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:

  1. request.get_json(silent=True) tries to parse JSON
  2. We validate name is a string
  3. Generate a UUID for a stable ID
  4. Store the item (in-memory for demo)
  5. Return 201 with 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:

  1. Look up the item; return 404 if missing.
  2. Require at least one field in the body (empty {}400).
  3. If name is present, validate and update it.
  4. Refresh updated_at and 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:

  1. Check if the item exists; return 404 if not.
  2. Remove it from fake_db.
  3. Return empty body with status 204.

Try it: curl examples

Health
curl http://127.0.0.1:5000/api/health
Create
curl -X POST http://127.0.0.1:5000/api/items \
  -H "Content-Type: application/json" \
  -d '{"name":"First item"}'
Get
curl http://127.0.0.1:5000/api/items/<ITEM_ID>
Update
curl -X PUT http://127.0.0.1:5000/api/items/<ITEM_ID> \
  -H "Content-Type: application/json" \
  -d '{"name":"Renamed"}'
Partial update (PATCH)
curl -X PATCH http://127.0.0.1:5000/api/items/<ITEM_ID> \
  -H "Content-Type: application/json" \
  -d '{"name":"Patched name"}'
Delete
curl -X DELETE http://127.0.0.1:5000/api/items/<ITEM_ID>

Create without JSON header (often fails in real clients)
curl -X POST http://127.0.0.1:5000/api/items -d '{"name":"x"}'
Create with missing name → 400
curl -X POST http://127.0.0.1:5000/api/items \
  -H "Content-Type: application/json" \
  -d '{}'
Get unknown ID → 404
curl http://127.0.0.1:5000/api/items/does-not-exist
PATCH with empty body → 400
curl -X PATCH http://127.0.0.1:5000/api/items/<ITEM_ID> \
  -H "Content-Type: application/json" \
  -d '{}'
Delete unknown ID → 404
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
Gunicorn example
uv add gunicorn
uv run gunicorn -w 4 -b 0.0.0.0:8000 app:app

What to learn next

  1. Blueprints (organize routes)
  2. Config (app.config + environment variables)
  3. Database (SQLAlchemy) + migrations
  4. Auth (JWT / session) and permissions
  5. Testing (pytest) + CI
  6. Observability (structured logs, metrics)

External references