Modern API design with Flask as the main example. FastAPI appears briefly where async and automatic OpenAPI docs matter. The focus is practical: clear URLs, consistent JSON, sensible status codes, and error bodies a front-end developer can actually use.
Resources and HTTP verbs
Think in resources (nouns), not actions (verbs in the URL). Use HTTP methods for the action: GET to read, POST to create, PUT/PATCH to update, DELETE to remove. Plural collection names are a common convention.
from flask import Flask, jsonify, request, abort
app = Flask(__name__)
TASKS: dict[int, dict] = {}
_next_id = 1
@app.get("/api/tasks")
def list_tasks():
return jsonify(list(TASKS.values()))
@app.post("/api/tasks")
def create_task():
global _next_id
data = request.get_json(silent=True) or {}
title = (data.get("title") or "").strip()
if not title:
abort(400, description="title is required")
task = {"id": _next_id, "title": title, "done": False}
TASKS[_next_id] = task
_next_id += 1
return jsonify(task), 201
@app.patch("/api/tasks/<int:task_id>")
def update_task(task_id: int):
task = TASKS.get(task_id)
if not task:
abort(404)
data = request.get_json(silent=True) or {}
if "done" in data:
task["done"] = bool(data["done"])
return jsonify(task)
In-memory storage keeps the example short. Real services swap in files, a database, or a repository module – the HTTP layer stays the same.
Consistent JSON shapes
Pick conventions and stick to them. Snake_case keys match Python and read well in JSON. Return the created resource on POST, not a bare success flag. Errors should use a predictable structure:
from flask import Flask, jsonify
app = Flask(__name__)
@app.errorhandler(400)
@app.errorhandler(404)
def api_error(error):
return jsonify({"error": error.description or "Request failed"}), error.code
Application factory and blueprints
Split routes by domain. The factory pattern from the intermediate Flask tutorial scales to APIs cleanly.
from flask import Blueprint, Flask
tasks_bp = Blueprint("tasks", __name__, url_prefix="/api/tasks")
@tasks_bp.get("")
def list_tasks():
...
Def create_app() -> Flask:
app = Flask(__name__)
app.register_blueprint(tasks_bp)
return app
Validation and serialisation
Validate input at the edge. Dataclasses or small Pydantic models (if you already depend on Pydantic) keep field lists explicit. Do not dump arbitrary request JSON straight into storage.
from dataclasses import asdict, dataclass
@dataclass
class TaskCreate:
title: str
@classmethod
def from_dict(cls, data: dict) -> "TaskCreate":
title = (data.get("title") or "").strip()
if not title:
raise ValueError("title is required")
return cls(title=title)
def task_to_json(task: TaskCreate, task_id: int, done: bool) -> dict:
return {"id": task_id, **asdict(task), "done": done}
Pagination and filtering
Large collections need limits. Accept limit and offset (or cursor tokens) as query parameters. Document defaults – for example max 100 items per page.
@app.get("/api/tasks")
def list_tasks():
limit = min(int(request.args.get("limit", 20)), 100)
offset = int(request.args.get("offset", 0))
items = list(TASKS.values())[offset : offset + limit]
return jsonify({"items": items, "limit": limit, "offset": offset})
FastAPI when async pays off
FastAPI generates OpenAPI docs and plays well with async endpoints. Use it when you are already async end to end – async database drivers, many concurrent upstream calls – not as a default replacement for a straightforward Flask service.
# FastAPI sketch - same resource idea, async handler
# from fastapi import FastAPI, HTTPException
# app = FastAPI()
#
# @app.get("/api/tasks")
# async def list_tasks():
# return list(TASKS.values())
Version your API in the path (/api/v1/tasks) when external clients depend on stability. For internal front ends you control, you can move faster – but still avoid breaking changes without warning.

