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 hint user_id: int
  • return 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=201 in the decorator
  • response_model documents and validates the response
  • EmailStr validates 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

FlaskFastAPINotes
@app.route("/path", methods=["GET"])@app.get("/path")Decorator per method
request.get_json()Parameter with a Pydantic modelAutomatic
request.args.get("q")q: str = Query(None)Query parameters
jsonify({})return {}Automatic serialization
return ..., 404raise HTTPException(404)HTTP exceptions
@app.before_requestMiddleware or Depends()Dependency injection
Blueprint("name")APIRouter(prefix="/name")Routers
g.userDepends(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) replace methods=[]
  • 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

  1. FastAPI - First Steps - The official step-by-step tutorial
  2. Pydantic v2 Documentation - Complete Pydantic reference
  3. FastAPI - Request Body - How FastAPI handles request bodies
  4. FastAPI - Path Parameters - Typed path parameters
  5. Flask to FastAPI Migration Guide - Official comparison
  6. HTTPException - FastAPI - Error handling in FastAPI

Module 5, Capsule 03 — Refactoring & Legacy Code with Claude Code Guide