Module 5: Framework and Language Migration
Flask to FastAPI — Step-by-Step Migration
Flask to FastAPI — Step-by-Step Migration
Capsule description
This is the most practical capsule of the module. You're going to migrate a Flask application to FastAPI endpoint by endpoint, with Claude Code doing the conversion and you verifying each step. It's not theory about migration — it's execution with real code you can replicate in your terminal.
The Flask→FastAPI case is ideal for learning because the frameworks are similar enough for the migration to be understandable, but different enough that the conversion requires real changes: decorators, validation (manual→Pydantic), async, dependency injection, and testing.
Claude Code is especially valuable here because it knows the APIs of both frameworks and can do the semantic conversion: it doesn't just replace @app.route with @app.get, it converts Flask patterns to their idiomatic equivalents in FastAPI.
Setup: Two Apps Side by Side
The original Flask app
# flask_app.py
from flask import Flask, request, jsonify
app = Flask(__name__)
users_db = {}
next_id = 1
@app.route("/health", methods=["GET"])
def health():
return jsonify({"status": "healthy", "framework": "flask"})
@app.route("/users", methods=["GET"])
def get_users():
return jsonify(list(users_db.values()))
@app.route("/users/<int:user_id>", methods=["GET"])
def get_user(user_id):
user = users_db.get(user_id)
if not user:
return jsonify({"error": "User not found"}), 404
return jsonify(user)
@app.route("/users", methods=["POST"])
def create_user():
data = request.get_json()
if not data.get("name"):
return jsonify({"error": "Name required"}), 400
if not data.get("email") or "@" not in data["email"]:
return jsonify({"error": "Valid email required"}), 400
global next_id
user = {
"id": next_id,
"name": data["name"],
"email": data["email"]
}
users_db[next_id] = user
next_id += 1
return jsonify(user), 201
@app.route("/users/<int:user_id>", methods=["PUT"])
def update_user(user_id):
user = users_db.get(user_id)
if not user:
return jsonify({"error": "User not found"}), 404
data = request.get_json()
if "name" in data:
user["name"] = data["name"]
if "email" in data:
if "@" not in data["email"]:
return jsonify({"error": "Valid email required"}), 400
user["email"] = data["email"]
return jsonify(user)
Create the empty FastAPI project
pip install fastapi uvicorn pydantic
# fastapi_app.py (empty, we'll fill it endpoint by endpoint)
from fastapi import FastAPI
app = FastAPI()
# The endpoints are migrated one at a time here
Endpoint-by-Endpoint Migration
Endpoint 1: GET /health (Complexity: Minimal)
# Prompt to Claude Code:
> "Migrate the GET /health endpoint from flask_app.py to
fastapi_app.py. Convert to idiomatic FastAPI."
Flask:
@app.route("/health", methods=["GET"])
def health():
return jsonify({"status": "healthy", "framework": "flask"})
FastAPI:
@app.get("/health")
def health():
return {"status": "healthy", "framework": "fastapi"}
Key differences:
@app.route("/health", methods=["GET"])→@app.get("/health")jsonify({})→{}(FastAPI serializes automatically)- Change from "flask" to "fastapi" in the response (intentional)
Verification:
> "Run the equivalence test for GET /health"
Endpoint 2: GET /users (Complexity: Low)
> "Migrate GET /users from Flask to FastAPI"
Flask:
@app.route("/users", methods=["GET"])
def get_users():
return jsonify(list(users_db.values()))
FastAPI:
@app.get("/users")
def get_users():
return list(users_db.values())
Difference: Only the decorator and removing jsonify.
Endpoint 3: GET /users/{id} (Complexity: Low-Medium)
> "Migrate GET /users/<user_id> from Flask to FastAPI.
Convert the 404 error handling to HTTPException."
Flask:
@app.route("/users/<int:user_id>", methods=["GET"])
def get_user(user_id):
user = users_db.get(user_id)
if not user:
return jsonify({"error": "User not found"}), 404
return jsonify(user)
FastAPI:
from fastapi import HTTPException
@app.get("/users/{user_id}")
def get_user(user_id: int):
user = users_db.get(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
Differences:
<int:user_id>→{user_id}with the type hintuser_id: intreturn jsonify(...), 404→raise HTTPException(404, ...)- FastAPI validates the type automatically (if you pass a string, it returns 422)
Endpoint 4: POST /users (Complexity: Medium)
This is the first endpoint with real validation — this is where Pydantic shines.
> "Migrate POST /users from Flask to FastAPI. Convert the
manual validation to a Pydantic model. Use response_model
to document the response type."
Flask (manual validation):
@app.route("/users", methods=["POST"])
def create_user():
data = request.get_json()
if not data.get("name"):
return jsonify({"error": "Name required"}), 400
if not data.get("email") or "@" not in data["email"]:
return jsonify({"error": "Valid email required"}), 400
# ... create user
FastAPI (validation with Pydantic):
from pydantic import BaseModel, EmailStr
class UserCreate(BaseModel):
name: str # Pydantic automatically validates it's not empty
email: EmailStr # Pydantic validates the email format
class UserResponse(BaseModel):
id: int
name: str
email: str
@app.post("/users", response_model=UserResponse, status_code=201)
def create_user(user_data: UserCreate):
global next_id
user = {
"id": next_id,
"name": user_data.name,
"email": user_data.email
}
users_db[next_id] = user
next_id += 1
return user
Significant differences:
- Manual
request.get_json()→ automatic Pydantic model - Inline validation (if/else) → type hints + Pydantic
status_code=201in the decoratorresponse_modeldocuments and validates the responseEmailStrvalidates the email better than"@" in email
Endpoint 5: PUT /users/{id} (Complexity: Medium)
> "Migrate PUT /users/<user_id> from Flask to FastAPI.
Use a Pydantic model with optional fields for the update."
FastAPI:
class UserUpdate(BaseModel):
name: str | None = None
email: EmailStr | None = None
@app.put("/users/{user_id}", response_model=UserResponse)
def update_user(user_id: int, user_data: UserUpdate):
user = users_db.get(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user_data.name is not None:
user["name"] = user_data.name
if user_data.email is not None:
user["email"] = user_data.email
return user
Flask → FastAPI Mapping
| Flask | FastAPI | Notes |
|---|---|---|
@app.route("/path", methods=["GET"]) | @app.get("/path") | Decorator per method |
request.get_json() | Parameter with a Pydantic model | Automatic |
request.args.get("q") | q: str = Query(None) | Query parameters |
jsonify({}) | return {} | Automatic serialization |
return ..., 404 | raise HTTPException(404) | HTTP exceptions |
@app.before_request | Middleware or Depends() | Dependency injection |
Blueprint("name") | APIRouter(prefix="/name") | Routers |
g.user | Depends(get_current_user) | DI instead of a global |
abort(404) | raise HTTPException(404) | Exceptions |
Generating the Complete Migration with Claude Code
The master prompt
> "Migrate the complete Flask app in flask_app.py to FastAPI
in fastapi_app.py. For each endpoint:
1. Convert the decorator
2. Convert manual validation to Pydantic models
3. Convert error handling to HTTPException
4. Convert request.get_json() to typed parameters
5. Remove jsonify() (FastAPI serializes automatically)
Create the necessary Pydantic models at the start of the file.
Keep the same business logic."
Connection with the Project
In the Module Project (capsule 06) you're going to execute this complete migration on a provided Flask app. This capsule gave you the endpoint-by-endpoint techniques. The project asks you to do it end-to-end.
Troubleshooting
Problem 1: FastAPI returns 422 instead of 400
Cause: Pydantic validation errors return 422 (Unprocessable Entity), not 400.
Solution: 422 is technically more correct. If you need 400, add a custom exception handler.
Problem 2: The IDs are different between Flask and FastAPI
Cause: If you use auto-increment, each app generates independent IDs.
Solution: In equivalence tests, compare structure and fields, not IDs.
Problem 3: Flask accepts inputs that FastAPI rejects
Cause: Pydantic is stricter than manual validation.
Solution: This is an improvement. Document it. If you need backward compatibility, relax the Pydantic model.
Exercises
Exercise 1: Convert a Flask decorator (Easy)
Convert these Flask decorators to FastAPI:
@app.route("/products", methods=["GET"])
@app.route("/products/<int:pid>", methods=["DELETE"])
@app.route("/orders", methods=["POST"])
See solution
@app.get("/products")
@app.delete("/products/{pid}")
@app.post("/orders")
Exercise 2: Create a Pydantic model (Medium)
Convert this manual Flask validation to a Pydantic model:
data = request.get_json()
if not data.get("title") or len(data["title"]) < 3:
return jsonify({"error": "Title must be 3+ chars"}), 400
if not isinstance(data.get("price"), (int, float)) or data["price"] <= 0:
return jsonify({"error": "Price must be positive"}), 400
if data.get("category") not in ["electronics", "books", "clothing"]:
return jsonify({"error": "Invalid category"}), 400
See solution
from pydantic import BaseModel, Field
from typing import Literal
class ProductCreate(BaseModel):
title: str = Field(min_length=3)
price: float = Field(gt=0)
category: Literal["electronics", "books", "clothing"]
Pydantic validates everything automatically. 3 lines replace 6 lines of manual validation.
Exercise 3: Migrate a complete endpoint (Hard)
Migrate this complete Flask endpoint to FastAPI:
@app.route("/orders", methods=["POST"])
def create_order():
data = request.get_json()
if not data.get("user_id"):
return jsonify({"error": "user_id required"}), 400
if not data.get("items") or len(data["items"]) == 0:
return jsonify({"error": "items required"}), 400
total = sum(item["price"] * item.get("qty", 1) for item in data["items"])
if total <= 0:
return jsonify({"error": "Total must be positive"}), 400
order = {"id": next_order_id(), "user_id": data["user_id"],
"items": data["items"], "total": round(total, 2), "status": "pending"}
orders_db[order["id"]] = order
return jsonify(order), 201
See solution
from pydantic import BaseModel, Field
from typing import List
class OrderItem(BaseModel):
price: float = Field(gt=0)
qty: int = Field(default=1, ge=1)
class OrderCreate(BaseModel):
user_id: int
items: List[OrderItem] = Field(min_length=1)
class OrderResponse(BaseModel):
id: int
user_id: int
items: list
total: float
status: str
@app.post("/orders", response_model=OrderResponse, status_code=201)
def create_order(order_data: OrderCreate):
total = sum(item.price * item.qty for item in order_data.items)
if total <= 0:
raise HTTPException(status_code=400, detail="Total must be positive")
order = {
"id": next_order_id(),
"user_id": order_data.user_id,
"items": [item.model_dump() for item in order_data.items],
"total": round(total, 2),
"status": "pending"
}
orders_db[order["id"]] = order
return order
Summary
- Flask→FastAPI is migrated endpoint by endpoint, from simple to complex
- Pydantic replaces manual validation with declarative type hints
- HTTPException replaces
return ..., status_code - Decorators per method (
@app.get,@app.post) replacemethods=[] - Claude Code converts semantically, not just syntactically
- Verify equivalence after each endpoint
Next capsule: Strangler Fig Pattern — how to make Flask and FastAPI coexist during the migration.
Additional Resources
- FastAPI - First Steps - The official step-by-step tutorial
- Pydantic v2 Documentation - Complete Pydantic reference
- FastAPI - Request Body - How FastAPI handles request bodies
- FastAPI - Path Parameters - Typed path parameters
- Flask to FastAPI Migration Guide - Official comparison
- HTTPException - FastAPI - Error handling in FastAPI
Module 5, Capsule 03 — Refactoring & Legacy Code with Claude Code Guide