Skip to content
elephantoo

Web apps & APIs with Flask and FastAPI

Lesson 36 of 38 17 min read

Routing, JSON APIs, templates and testing in Flask; type-driven validation and auto docs in FastAPI.


Python powers the back-ends of Instagram, Spotify, Reddit and countless start-ups. Two frameworks dominate new projects: Flask, a minimal, flexible framework that's been a favourite for over a decade, and FastAPI, a modern framework built on type hints and async, designed for high-performance JSON APIs. (The third big name, Django, is a "batteries-included" framework with an ORM and admin panel — worth learning once you know the basics here.) In this lesson you'll build the same small API in both, and test them properly.

How a web app works#

A browser or client sends an HTTP request (method + path + headers + body). Your framework routes it to a Python function (a view or endpoint), which returns a response — HTML for web pages, or JSON for APIs. You already know the client side from the HTTP lesson; now you're writing the server.

Flask: a minimal app#

Terminal
pip install flask
hello_flask.py
from flask import Flask

app = Flask(__name__)


@app.get("/")
def home():
    return "<h1>Hello from Flask!</h1>"


@app.get("/greet/<name>")
def greet(name):
    return {"message": f"Hello, {name}!"}       # dicts are returned as JSON

Run the development server and visit http://127.0.0.1:5000/greet/Ada:

Terminal
flask --app hello_flask run --debug
Terminal
curl http://127.0.0.1:5000/greet/Ada
Output
{"message":"Hello, Ada!"}

@app.get("/") registers the function for GET requests to that path (it's shorthand for @app.route("/", methods=["GET"])). --debug enables auto-reload when you save files and an in-browser debugger — never use it in production.

Flask: a JSON API#

Let's build a small to-do API with in-memory storage:

todo_flask.py
from flask import Flask, abort, jsonify, request

app = Flask(__name__)
todos = {1: {"id": 1, "title": "Learn Flask", "done": False}}
next_id = 2


@app.get("/todos")
def list_todos():
    done = request.args.get("done")                 # query string: /todos?done=true
    items = list(todos.values())
    if done is not None:
        items = [t for t in items if t["done"] == (done.lower() == "true")]
    return jsonify(items)


@app.get("/todos/<int:todo_id>")
def get_todo(todo_id):
    if todo_id not in todos:
        abort(404, description="todo not found")
    return todos[todo_id]


@app.post("/todos")
def create_todo():
    global next_id
    data = request.get_json(silent=True) or {}
    title = str(data.get("title", "")).strip()
    if not title:
        return {"error": "title is required"}, 400  # (body, status) tuple
    todo = {"id": next_id, "title": title, "done": False}
    todos[next_id] = todo
    next_id += 1
    return todo, 201


@app.patch("/todos/<int:todo_id>")
def update_todo(todo_id):
    todo = todos.get(todo_id) or abort(404)
    data = request.get_json(silent=True) or {}
    if "done" in data:
        todo["done"] = bool(data["done"])
    return todo


@app.errorhandler(404)
def not_found(err):
    return {"error": err.description}, 404

Key Flask tools: request.args (query string), request.get_json() (JSON body), request.form (HTML forms), abort(status) to stop with an error, and returning a (body, status) tuple to set the status code.

Testing Flask with the test client

You don't need a running server to test: Flask's test client sends fake requests straight to your app. This is how you'd write pytest tests for it:

check_flask.py
from todo_flask import app

client = app.test_client()

r = client.post("/todos", json={"title": "Write tests"})
print(r.status_code, r.get_json())

print(client.post("/todos", json={}).get_json())
client.patch("/todos/2", json={"done": True})
print([t["title"] for t in client.get("/todos?done=true").get_json()])

r = client.get("/todos/99")
print(r.status_code, r.get_json())
Output
201 {'done': False, 'id': 2, 'title': 'Write tests'}
{'error': 'title is required'}
['Write tests']
404 {'error': 'todo not found'}

HTML pages with templates

Flask renders HTML with Jinja2 templates, stored in a templates/ folder:

templates/todos.html
<!doctype html>
<title>Todos</title>
<h1>{{ heading }}</h1>
<ul>
  {% for t in todos %}
    <li>{{ t.title }}{% if t.done %} ✓{% endif %}</li>
  {% endfor %}
</ul>
pages_flask.py
from flask import Flask, render_template

app = Flask(__name__)


@app.get("/")
def index():
    todos = [{"title": "Learn Flask", "done": True}, {"title": "<script>alert(1)</script>", "done": False}]
    return render_template("todos.html", heading="My todos", todos=todos)


if __name__ == "__main__":
    html = app.test_client().get("/").get_data(as_text=True)
    print("\n".join(line.strip() for line in html.splitlines() if "<li>" in line))
Output
<li>Learn Flask ✓</li>
<li>&lt;script&gt;alert(1)&lt;/script&gt;</li>

Jinja escapes values automatically, so user input can't inject scripts (XSS). Flask's ecosystem adds what you need as you grow: Flask-SQLAlchemy (database), Flask-Login (sessions), Flask-WTF (forms), and blueprints to split large apps into modules.

FastAPI: type-driven APIs#

Terminal
pip install "fastapi[standard]"

FastAPI uses type hints and Pydantic models to validate input, convert types and generate documentation — automatically:

todo_fastapi.py
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field

app = FastAPI(title="Todo API")


class TodoIn(BaseModel):
    title: str = Field(min_length=1, max_length=100)


class Todo(TodoIn):
    id: int
    done: bool = False


todos: dict[int, Todo] = {1: Todo(id=1, title="Learn FastAPI")}


@app.get("/todos")
def list_todos(done: bool | None = None) -> list[Todo]:     # ?done=true is parsed to bool
    items = list(todos.values())
    return items if done is None else [t for t in items if t.done == done]


@app.get("/todos/{todo_id}")
def get_todo(todo_id: int) -> Todo:                         # path param converted to int
    if todo_id not in todos:
        raise HTTPException(status_code=404, detail="todo not found")
    return todos[todo_id]


@app.post("/todos", status_code=status.HTTP_201_CREATED)
def create_todo(payload: TodoIn) -> Todo:                   # JSON body validated by Pydantic
    todo = Todo(id=max(todos, default=0) + 1, title=payload.title)
    todos[todo.id] = todo
    return todo


@app.get("/slow")
async def slow():                                           # async endpoints are supported
    import asyncio
    await asyncio.sleep(0.1)
    return {"ok": True}

Run it with the development server:

Terminal
fastapi dev todo_fastapi.py

Then open http://127.0.0.1:8000/docs — FastAPI has generated interactive documentation (Swagger UI) from your code, where you can try every endpoint. The machine-readable OpenAPI schema is at /openapi.json, from which client SDKs can be generated.

Testing FastAPI and automatic validation

check_fastapi.py
from fastapi.testclient import TestClient

from todo_fastapi import app

client = TestClient(app)

r = client.post("/todos", json={"title": "Ship it"})
print(r.status_code, r.json())

r = client.post("/todos", json={"title": ""})               # fails validation
print(r.status_code, r.json()["detail"][0]["msg"])

r = client.get("/todos/abc")                                 # not an int
print(r.status_code, r.json()["detail"][0]["type"])

print(client.get("/todos", params={"done": "false"}).json())
print(client.get("/todos/42").json(), client.get("/slow").json())
Output
201 {'title': 'Ship it', 'id': 2, 'done': False}
422 String should have at least 1 character
422 int_parsing
[{'title': 'Learn FastAPI', 'id': 1, 'done': False}, {'title': 'Ship it', 'id': 2, 'done': False}]
{'detail': 'todo not found'} {'ok': True}

We wrote no validation code, yet invalid input was rejected with a precise 422 Unprocessable Content response. That's the power of type hints at runtime.

FastAPI also offers dependency injection (Depends) for things like database sessions and authentication, background tasks, WebSockets, and first-class async support — remember from the concurrency lesson not to block the event loop inside async def endpoints (use plain def endpoints for blocking code; FastAPI runs those in a thread pool).

Flask or FastAPI?#

FlaskFastAPI
Styleminimal, explicittype hints drive everything
Validationadd a library (e.g. Pydantic, Marshmallow)built in (Pydantic)
API docsvia extensionsautomatic /docs
Asyncsupported, but sync-firstasync-first
Great forserver-rendered sites, simple APIs, huge ecosystemJSON APIs, microservices, ML model serving

Both are excellent and widely used in industry — the concepts (routing, request parsing, responses, status codes, testing) transfer directly between them, and to Django.

Configuration and deployment basics#

  • Configuration from the environment: read secrets and database URLs from environment variables (or a .env file in development), never from code.
  • Production servers: run Flask with Gunicorn (gunicorn -w 4 todo_flask:app) and FastAPI with Uvicorn workers (uvicorn todo_fastapi:app --host 0.0.0.0 --port 8000 --workers 4, or fastapi run), usually behind Nginx or a cloud load balancer that handles HTTPS.
  • Containers: most teams ship apps as Docker images to services like AWS, Google Cloud Run, Azure, Render or Fly.io.
  • Persistence: swap the in-memory dict for a real database (SQLAlchemy works with both frameworks).

Common mistakes#

  • Running the debug/dev server in production.
  • Storing data in global variables — it vanishes on restart and isn't shared between worker processes. Use a database.
  • Not validating input — never trust request data. (FastAPI makes this easy; in Flask, validate explicitly.)
  • Returning 200 for errors — use proper status codes (400, 401, 404, 422, 500).
  • Blocking calls inside async def endpoints.
  • Hard-coding secrets like API keys or SECRET_KEY.

What's next#

Your project is growing into a real application. Next: packaging and project structure — how to lay out, configure and distribute a professional Python project.

Check your understanding

Quick quiz

0/3 answered
  1. 1.In Flask, what does the decorator @app.get("/users/<int:user_id>") do?

  2. 2.What does FastAPI use type hints and Pydantic models for?

  3. 3.Why shouldn't you deploy with flask run --debug or fastapi dev?

Finished reading?

Mark this lesson complete to track your progress.