Case Study 2: Migrating from Flask to FastAPI
Overview
This case study follows a realistic migration scenario: a team has an existing Flask API for a bookstore application that they want to migrate to FastAPI. The migration is motivated by three needs: automatic API documentation, better request validation, and async support for improved performance under load.
We demonstrate how AI coding assistants can accelerate this migration while highlighting the areas where human judgment is essential. The migration is performed incrementally -- endpoint by endpoint -- rather than as a big-bang rewrite, which reduces risk and allows the team to validate each change.
The Existing Flask Application
The original Flask application is a bookstore API with the following features:
- Book CRUD (create, read, update, delete)
- Author management
- User authentication with Flask-JWT-Extended
- Search functionality
- Basic error handling
Here is a representative sample of the Flask codebase:
from flask import Flask, jsonify, request, g
from flask_jwt_extended import (
JWTManager, create_access_token, jwt_required, get_jwt_identity
)
from functools import wraps
import re
app = Flask(__name__)
app.config["JWT_SECRET_KEY"] = "super-secret-key"
jwt_manager = JWTManager(app)
# In-memory storage
books_db = {}
authors_db = {}
users_db = {}
next_book_id = 1
def admin_required(fn):
"""Decorator to require admin role."""
@wraps(fn)
@jwt_required()
def wrapper(*args, **kwargs):
user_id = get_jwt_identity()
user = users_db.get(user_id)
if not user or user.get("role") != "admin":
return jsonify({"error": "Admin access required"}), 403
return fn(*args, **kwargs)
return wrapper
@app.route("/api/books", methods=["GET"])
def get_books():
"""Get all books with optional filtering."""
genre = request.args.get("genre")
author_id = request.args.get("author_id", type=int)
page = request.args.get("page", 1, type=int)
per_page = request.args.get("per_page", 20, type=int)
results = list(books_db.values())
if genre:
results = [b for b in results if b.get("genre") == genre]
if author_id:
results = [b for b in results if b.get("author_id") == author_id]
# Manual pagination
total = len(results)
start = (page - 1) * per_page
end = start + per_page
return jsonify({
"books": results[start:end],
"total": total,
"page": page,
"per_page": per_page
})
@app.route("/api/books", methods=["POST"])
@jwt_required()
def create_book():
"""Create a new book."""
global next_book_id
data = request.get_json()
# Manual validation
errors = []
if not data.get("title"):
errors.append("Title is required")
if not data.get("author_id"):
errors.append("Author ID is required")
if data.get("isbn") and not re.match(r"^\d{13}$", data["isbn"]):
errors.append("ISBN must be 13 digits")
if data.get("price") is not None and data["price"] < 0:
errors.append("Price cannot be negative")
if data.get("published_year"):
if not isinstance(data["published_year"], int):
errors.append("Published year must be an integer")
elif data["published_year"] < 1000 or data["published_year"] > 2030:
errors.append("Published year must be between 1000 and 2030")
if errors:
return jsonify({"errors": errors}), 400
book = {
"id": next_book_id,
"title": data["title"],
"author_id": data["author_id"],
"isbn": data.get("isbn"),
"genre": data.get("genre", "uncategorized"),
"price": data.get("price", 0.0),
"published_year": data.get("published_year"),
"description": data.get("description", ""),
}
books_db[next_book_id] = book
next_book_id += 1
return jsonify(book), 201
@app.route("/api/books/<int:book_id>", methods=["PUT"])
@jwt_required()
def update_book(book_id):
"""Update an existing book."""
if book_id not in books_db:
return jsonify({"error": "Book not found"}), 404
data = request.get_json()
book = books_db[book_id]
# Manual field updates
if "title" in data:
book["title"] = data["title"]
if "author_id" in data:
book["author_id"] = data["author_id"]
if "isbn" in data:
book["isbn"] = data["isbn"]
if "genre" in data:
book["genre"] = data["genre"]
if "price" in data:
book["price"] = data["price"]
return jsonify(book), 200
@app.route("/api/books/search", methods=["GET"])
def search_books():
"""Search books by title or description."""
query = request.args.get("q", "").lower()
if not query:
return jsonify({"error": "Search query required"}), 400
results = [
b for b in books_db.values()
if query in b.get("title", "").lower()
or query in b.get("description", "").lower()
]
return jsonify({"results": results, "count": len(results)})
@app.errorhandler(404)
def not_found(error):
return jsonify({"error": "Resource not found"}), 404
@app.errorhandler(500)
def server_error(error):
return jsonify({"error": "Internal server error"}), 500
This Flask application works, but it has several pain points:
- Manual validation is verbose, error-prone, and inconsistent
- No automatic documentation -- the team maintains a separate Markdown file that is often out of date
- Synchronous only -- under load, the server blocks on I/O operations
- Manual serialization -- converting between database records and JSON requires manual field mapping
Migration Strategy
We adopt an incremental migration strategy with four phases:
- Set up FastAPI project structure with equivalent configuration
- Migrate data models from ad-hoc dicts to Pydantic models
- Migrate endpoints one router at a time
- Migrate authentication from Flask-JWT-Extended to FastAPI's security system
Phase 1: Project Structure
Prompt 1: Project Scaffolding
"I'm migrating a Flask bookstore API to FastAPI. Create the FastAPI project structure with: main.py (app creation with lifespan), config.py (Pydantic settings), and a routers/ directory with books.py, authors.py, and auth.py. The Flask app uses JWT_SECRET_KEY config and runs on port 5000. Set up equivalent configuration in FastAPI."
The AI generates a clean project structure. Notably, the Pydantic Settings class replaces Flask's config dictionary:
# config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Application settings, loaded from environment variables."""
app_name: str = "Bookstore API"
debug: bool = False
jwt_secret_key: str = "change-me-in-production"
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 30
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
settings = Settings()
Phase 2: Pydantic Models
This is where the migration pays the biggest dividends. All that manual validation code in Flask gets replaced by declarative Pydantic models.
Prompt 2: Convert Validation to Pydantic
"Convert this Flask manual validation code for book creation into Pydantic models. Here is the Flask validation: [paste the manual validation code from create_book] Create BookCreate, BookUpdate (all fields optional), and BookResponse Pydantic models with equivalent validation rules. Use Field constraints instead of manual checks."
from typing import Optional
from pydantic import BaseModel, Field
class BookCreate(BaseModel):
"""Schema for creating a new book."""
title: str = Field(..., min_length=1, max_length=300,
description="The title of the book")
author_id: int = Field(..., gt=0,
description="ID of the book's author")
isbn: Optional[str] = Field(None, pattern=r"^\d{13}$",
description="13-digit ISBN")
genre: str = Field(default="uncategorized", max_length=50,
description="Book genre")
price: float = Field(default=0.0, ge=0,
description="Price in dollars")
published_year: Optional[int] = Field(None, ge=1000, le=2030,
description="Year of publication")
description: str = Field(default="", max_length=5000,
description="Book description")
class BookUpdate(BaseModel):
"""Schema for updating a book. All fields optional."""
title: Optional[str] = Field(None, min_length=1, max_length=300)
author_id: Optional[int] = Field(None, gt=0)
isbn: Optional[str] = Field(None, pattern=r"^\d{13}$")
genre: Optional[str] = Field(None, max_length=50)
price: Optional[float] = Field(None, ge=0)
published_year: Optional[int] = Field(None, ge=1000, le=2030)
description: Optional[str] = Field(None, max_length=5000)
class BookResponse(BookCreate):
"""Schema for book in API responses."""
id: int
The 20 lines of manual validation code in Flask are replaced by declarative field constraints that are simultaneously validation rules, documentation, and IDE assistance. This is the single biggest quality-of-life improvement in the migration.
Phase 3: Endpoint Migration
We migrate one endpoint at a time, starting with the simplest.
Prompt 3: Migrate GET Endpoint
"Convert this Flask endpoint to FastAPI. Keep the same URL structure and query parameters. Use Pydantic models for the response and FastAPI's Query for parameter validation: [paste the get_books Flask code]"
from fastapi import APIRouter, Query
from typing import Optional
router = APIRouter(prefix="/api/books", tags=["books"])
@router.get("/", response_model=PaginatedBooks)
async def get_books(
genre: Optional[str] = Query(None, description="Filter by genre"),
author_id: Optional[int] = Query(None, gt=0, description="Filter by author ID"),
page: int = Query(1, ge=1, description="Page number"),
per_page: int = Query(20, ge=1, le=100, description="Items per page"),
):
"""Get all books with optional filtering and pagination."""
results = list(books_db.values())
if genre:
results = [b for b in results if b.get("genre") == genre]
if author_id:
results = [b for b in results if b.get("author_id") == author_id]
total = len(results)
start = (page - 1) * per_page
end = start + per_page
return {
"books": results[start:end],
"total": total,
"page": page,
"per_page": per_page,
}
Key improvements in the FastAPI version:
- Query parameters are typed and validated. per_page cannot exceed 100, page must be positive.
- Auto-documentation. Each parameter has a description that appears in the Swagger UI.
- Response model validation. The response_model ensures the response matches the expected schema.
Prompt 4: Migrate POST Endpoint
"Convert this Flask POST /api/books endpoint to FastAPI. Replace all manual validation with the BookCreate Pydantic model. Replace @jwt_required() with a FastAPI dependency for authentication. Return 201 status code."
@router.post("/", response_model=BookResponse, status_code=201)
async def create_book(
book: BookCreate,
current_user: dict = Depends(get_current_user),
):
"""Create a new book. Requires authentication."""
book_id = next_id()
book_data = {"id": book_id, **book.model_dump()}
books_db[book_id] = book_data
return book_data
Notice how the entire create_book endpoint shrinks from 30+ lines (with manual validation) in Flask to about 5 lines of actual logic in FastAPI. The validation happens automatically through the Pydantic model.
Phase 4: Authentication Migration
This is the most delicate part of the migration because authentication is security-critical.
Prompt 5: Migrate Authentication
"Migrate this Flask-JWT-Extended authentication to FastAPI. Convert the @jwt_required() decorator to a FastAPI Depends() pattern. Convert the admin_required decorator to a dependency. Use python-jose instead of Flask-JWT-Extended. The login endpoint currently uses Flask-JWT-Extended's create_access_token -- convert to manual JWT creation with python-jose."
The AI generates the FastAPI authentication system. Key mapping:
| Flask (Flask-JWT-Extended) | FastAPI (python-jose) |
|---|---|
@jwt_required() |
Depends(get_current_user) |
get_jwt_identity() |
Extracted from JWT payload in dependency |
create_access_token(identity=user_id) |
jwt.encode({"sub": user_id, ...}, SECRET_KEY, algorithm=ALGORITHM) |
@admin_required decorator |
Depends(require_admin) dependency |
# Flask version
@app.route("/api/books/<int:book_id>", methods=["DELETE"])
@admin_required
def delete_book(book_id):
if book_id not in books_db:
return jsonify({"error": "Book not found"}), 404
del books_db[book_id]
return "", 204
# FastAPI version
@router.delete("/{book_id}", status_code=204)
async def delete_book(
book_id: int,
current_user: dict = Depends(require_admin),
):
"""Delete a book. Requires admin role."""
if book_id not in books_db:
raise HTTPException(status_code=404, detail="Book not found")
del books_db[book_id]
return None
Migration Challenges and Solutions
Challenge 1: Flask Extensions Without Direct Equivalents
Flask-Limiter does not have a direct FastAPI equivalent. We asked the AI to implement custom rate limiting middleware instead.
Prompt: "Flask-Limiter is used in the Flask app with @limiter.limit('10/minute'). Create an equivalent rate limiting system for FastAPI using middleware. Support per-endpoint rate limits defined as a dependency."
Challenge 2: Error Response Format Changes
The Flask app returned errors in different formats depending on the error type. Some used {"error": "message"}, others used {"errors": ["msg1", "msg2"]}. FastAPI's built-in validation errors use yet another format.
Prompt: "Create a unified error handling system for FastAPI that converts all error types (validation errors, HTTP exceptions, and custom exceptions) to the same format: {error: {code: 'ERROR_CODE', message: '...', details: {}}}. Override FastAPI's default validation error handler."
Challenge 3: Search Endpoint URL Pattern
The Flask app used /api/books/search (a verb in the URL). We debated whether to preserve the URL for backward compatibility or fix it. We chose to support both during a transition period:
@router.get("/search", deprecated=True, include_in_schema=True)
async def search_books_legacy(q: str = Query(...)):
"""DEPRECATED: Use GET /api/books?search=query instead."""
return await list_books(search=q)
@router.get("/", response_model=PaginatedBooks)
async def list_books(search: Optional[str] = Query(None)):
"""List books. Use search parameter for text search."""
pass
The deprecated=True flag marks the old endpoint in the OpenAPI docs, guiding consumers toward the new pattern.
Challenge 4: Testing Compatibility
The Flask app used pytest with Flask's test client. FastAPI uses httpx.AsyncClient. We prompted AI to convert the existing tests:
"Convert these Flask test cases using Flask's test_client to FastAPI tests using httpx.AsyncClient and pytest-asyncio. Preserve all test assertions."
Results
After the migration, the team observed:
- 60% reduction in validation code. Manual validation was replaced by Pydantic models.
- Auto-generated documentation. The separate Markdown doc was replaced by always-up-to-date Swagger UI at
/docs. - Type safety. IDE autocompletion and type checking caught several bugs during migration.
- Performance improvement. Under load testing with 500 concurrent users, the FastAPI version handled 3x more requests per second than the Flask version for I/O-bound endpoints.
- Improved developer experience. New team members could understand the API by browsing
/docsrather than reading source code.
Lessons Learned
-
Migrate incrementally. One endpoint at a time, with tests running after each change. The AI can convert individual endpoints accurately; asking it to convert the entire application at once produces more errors.
-
Pydantic models are the highest-value migration target. Converting manual validation to Pydantic models provides the most immediate benefit.
-
Authentication requires careful manual review. Even though AI generates correct authentication code most of the time, this is too security-critical for anything less than thorough manual review.
-
Preserve backward compatibility. Use deprecated endpoints and response aliasing to give API consumers time to adapt.
-
AI excels at mechanical translation. Converting Flask route definitions to FastAPI decorators, converting request.args to Query parameters, and converting jsonify returns to Pydantic models are all mechanical translations that AI handles accurately. Creative design decisions (URL restructuring, error format changes) require human judgment.
The complete migration code is available in code/case-study-code.py.