← Back to DevBytes

Migrating from Django to FastAPI: Step-by-Step Guide

Understanding the Django to FastAPI Migration

Migrating from Django to FastAPI means transitioning your web application from a traditional, full-stack Python framework to a modern, high-performance async framework designed primarily for APIs. Django follows the Model-View-Template (MVT) architecture with batteries includedβ€”ORM, admin panel, authentication, and templating engine all bundled together. FastAPI, on the other hand, is laser-focused on building RESTful and GraphQL APIs with automatic interactive documentation, native async support, and data validation through Pydantic models.

This migration isn't about rewriting every line of code blindly. It's about strategically decoupling your backend logic from Django's monolithic structure and rearchitecting it around FastAPI's lightweight, explicit, and type-driven design. The process typically involves moving your business logic, data access layer, and API endpoints while potentially keeping Django for administrative tasks during a transitional phase.

When Should You Consider Migrating?

Why This Migration Matters

πŸš€ Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

The decision to migrate carries significant technical and organizational implications. FastAPI brings concrete benefits that directly impact performance, developer experience, and maintainability.

Performance Gains

FastAPI, built on Starlette and Uvicorn, is one of the fastest Python web frameworks available. It achieves near-parity with Node.js and Go in many benchmarks. Django's synchronous request-response cycle, even with ASGI adapters, cannot match FastAPI's native async architecture for I/O-bound workloads. When your endpoints make multiple external API calls, database queries, or file operations, FastAPI's concurrency model dramatically reduces total response time by overlapping waiting periods.

Automatic API Documentation

Every FastAPI application automatically serves interactive Swagger UI and ReDoc documentation at /docs and /redoc. This isn't a plugin or add-onβ€”it's built into the framework's core. Django requires packages like drf-yasg or drf-spectacular to achieve similar results, and the configuration can be cumbersome. With FastAPI, your Pydantic models and type annotations become the single source of truth for validation, serialization, and documentation simultaneously.

Type Safety and Data Validation

FastAPI leverages Pydantic for request and response validation. This means invalid data is caught at the edge of your application before it reaches your business logic. Django REST Framework serializers serve a similar purpose, but they're less strict about type enforcement and don't integrate with Python's type hinting system as deeply. With FastAPI, editors like PyCharm and VS Code provide better autocompletion and inline error detection because the framework respects type annotations natively.

Simplified Deployment and Resource Usage

A typical FastAPI application has a smaller memory footprint than a comparable Django application. There's no middleware stack you didn't explicitly add, no template engine loading by default, and no admin interface consuming resources. This makes FastAPI ideal for containerized deployments where you want to minimize per-instance resource consumption.

Step-by-Step Migration Guide

Step 1: Audit Your Existing Django Application

Before writing any FastAPI code, catalog every component of your Django application. Create an inventory spreadsheet or document covering:

This audit reveals which parts can migrate directly, which need redesign, and which might stay on Django temporarily. It also helps you estimate the migration scope realistically.

Step 2: Set Up the FastAPI Project Structure

Create a new project directory alongside your Django project (not inside it). Install the core dependencies:

pip install fastapi uvicorn sqlalchemy pydantic alembic python-jose[cryptography] passlib[bcrypt] python-multipart

Organize your FastAPI project with a clean, modular structure:

fastapi_app/
β”œβ”€β”€ main.py              # Application entry point
β”œβ”€β”€ config.py            # Settings and environment variables
β”œβ”€β”€ database.py          # Database engine and session management
β”œβ”€β”€ models/
β”‚   β”œβ”€β”€ __init__.py
β”‚   β”œβ”€β”€ user.py          # SQLAlchemy ORM models
β”‚   └── order.py
β”œβ”€β”€ schemas/
β”‚   β”œβ”€β”€ __init__.py
β”‚   β”œβ”€β”€ user.py          # Pydantic schemas for request/response
β”‚   └── order.py
β”œβ”€β”€ api/
β”‚   β”œβ”€β”€ __init__.py
β”‚   β”œβ”€β”€ dependencies.py  # Shared FastAPI dependencies
β”‚   └── v1/
β”‚       β”œβ”€β”€ __init__.py
β”‚       β”œβ”€β”€ router.py    # Aggregates all v1 routes
β”‚       β”œβ”€β”€ users.py     # User endpoints
β”‚       └── orders.py    # Order endpoints
β”œβ”€β”€ services/
β”‚   β”œβ”€β”€ __init__.py
β”‚   β”œβ”€β”€ user_service.py  # Business logic layer
β”‚   └── order_service.py
β”œβ”€β”€ core/
β”‚   β”œβ”€β”€ __init__.py
β”‚   β”œβ”€β”€ security.py      # Authentication and hashing
β”‚   └── middleware.py    # Custom middleware
└── tests/
    β”œβ”€β”€ __init__.py
    β”œβ”€β”€ conftest.py
    └── test_users.py

This structure separates concerns clearly: routing lives in api/, business logic in services/, data access in models/, and validation in schemas/. It scales well as your application grows.

Step 3: Migrate Models from Django ORM to SQLAlchemy

This is the most substantial part of the migration. Django models are class-based with field declarations as class attributes. SQLAlchemy uses a similar declarative pattern, but with different syntax and more explicit configuration.

Consider a typical Django model:

# Django original
from django.db import models
from django.contrib.auth.models import AbstractUser

class User(AbstractUser):
    bio = models.TextField(blank=True)
    avatar_url = models.URLField(blank=True)
    date_modified = models.DateTimeField(auto_now=True)

class Order(models.Model):
    STATUS_CHOICES = [
        ('pending', 'Pending'),
        ('shipped', 'Shipped'),
        ('delivered', 'Delivered'),
    ]
    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='orders')
    status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
    total_amount = models.DecimalField(max_digits=10, decimal_places=2)
    created_at = models.DateTimeField(auto_now_add=True)
    items = models.JSONField(default=list)

The equivalent SQLAlchemy models look like this:

# models/user.py
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from database import Base
import datetime

class User(Base):
    __tablename__ = "users"
    
    id = Column(Integer, primary_key=True, index=True)
    username = Column(String(150), unique=True, nullable=False, index=True)
    email = Column(String(254), unique=True, nullable=False, index=True)
    password_hash = Column(String(128), nullable=False)
    first_name = Column(String(150), default="")
    last_name = Column(String(150), default="")
    bio = Column(Text, default="")
    avatar_url = Column(String(500), default="")
    is_active = Column(Boolean, default=True)
    is_superuser = Column(Boolean, default=False)
    is_staff = Column(Boolean, default=False)
    date_joined = Column(DateTime(timezone=True), server_default=func.now())
    date_modified = Column(DateTime(timezone=True), onupdate=func.now())
    
    orders = relationship("Order", back_populates="user", cascade="all, delete-orphan")

# models/order.py
from sqlalchemy import Column, Integer, String, Numeric, DateTime, ForeignKey, JSON
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from database import Base

class Order(Base):
    __tablename__ = "orders"
    
    id = Column(Integer, primary_key=True, index=True)
    user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
    status = Column(String(20), default="pending")
    total_amount = Column(Numeric(10, 2), nullable=False)
    created_at = Column(DateTime(timezone=True), server_default=func.now())
    items = Column(JSON, default=list)
    
    user = relationship("User", back_populates="orders")

Key differences to note: Django's ForeignKey uses the model class reference, while SQLAlchemy uses the table name string. Django automatically adds the id primary key; SQLAlchemy requires explicit declaration. Django's auto_now and auto_now_add are replaced with SQLAlchemy's onupdate and server_default. Relationships are declared on both sides explicitly in SQLAlchemy.

Step 4: Create Pydantic Schemas for Data Validation

Pydantic schemas replace Django REST Framework serializers. They define the shape of incoming requests and outgoing responses with strict type enforcement.

# schemas/user.py
from pydantic import BaseModel, EmailStr, Field, ConfigDict
from datetime import datetime
from typing import Optional

class UserBase(BaseModel):
    username: str = Field(..., min_length=3, max_length=150, pattern=r'^[\w.@+-]+$')
    email: EmailStr
    first_name: Optional[str] = Field(default="", max_length=150)
    last_name: Optional[str] = Field(default="", max_length=150)
    bio: Optional[str] = Field(default="", max_length=500)
    avatar_url: Optional[str] = Field(default="", max_length=500)

class UserCreate(UserBase):
    password: str = Field(..., min_length=8, max_length=128)

class UserUpdate(BaseModel):
    first_name: Optional[str] = Field(None, max_length=150)
    last_name: Optional[str] = Field(None, max_length=150)
    bio: Optional[str] = Field(None, max_length=500)
    avatar_url: Optional[str] = Field(None, max_length=500)

class UserResponse(UserBase):
    id: int
    is_active: bool
    is_superuser: bool
    date_joined: datetime
    date_modified: Optional[datetime]
    
    model_config = ConfigDict(from_attributes=True)

class UserLogin(BaseModel):
    username: str
    password: str

class TokenResponse(BaseModel):
    access_token: str
    token_type: str = "bearer"

The ConfigDict(from_attributes=True) (formerly orm_mode) tells Pydantic to read data from ORM model attributes rather than requiring dictionaries. This enables seamless conversion from SQLAlchemy query results to API responses.

For the Order schema:

# schemas/order.py
from pydantic import BaseModel, Field, ConfigDict
from datetime import datetime
from typing import Optional, List

class OrderItem(BaseModel):
    product_name: str
    quantity: int = Field(..., gt=0)
    unit_price: float = Field(..., gt=0)

class OrderCreate(BaseModel):
    items: List[OrderItem]
    status: str = Field(default="pending", pattern=r'^(pending|shipped|delivered)$')

class OrderResponse(BaseModel):
    id: int
    user_id: int
    status: str
    total_amount: float
    created_at: datetime
    items: List[OrderItem]
    
    model_config = ConfigDict(from_attributes=True)

Step 5: Set Up Database Connection and Sessions

FastAPI doesn't provide an ORM out of the box, so you configure SQLAlchemy explicitly. Create a database module that manages engine creation, session factories, and dependency injection:

# database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from config import settings

engine = create_engine(
    settings.DATABASE_URL,
    pool_size=20,
    max_overflow=0,
    pool_pre_ping=True,
    echo=settings.DEBUG
)

SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

class Base(DeclarativeBase):
    pass

def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

The get_db generator function is crucial. FastAPI's dependency injection system will call it for each request, providing a fresh database session and ensuring proper cleanup after the response is sent. This replaces Django's automatic request-scoped database connection management.

Step 6: Migrate Django Views to FastAPI Endpoints

This is where the architectural shift becomes most visible. Django views (whether function-based or class-based) combine request handling, parameter extraction, business logic invocation, and response rendering. FastAPI separates these concerns more explicitly through type-annotated path operation functions.

Here's a Django REST Framework view:

# Django DRF original
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from .models import Order
from .serializers import OrderSerializer

@api_view(['GET'])
@permission_classes([IsAuthenticated])
def list_orders(request):
    orders = Order.objects.filter(user=request.user).order_by('-created_at')
    serializer = OrderSerializer(orders, many=True)
    return Response(serializer.data)

@api_view(['POST'])
@permission_classes([IsAuthenticated])
def create_order(request):
    serializer = OrderSerializer(data=request.data)
    if serializer.is_valid():
        order = serializer.save(user=request.user)
        return Response(OrderSerializer(order).data, status=status.HTTP_201_CREATED)
    return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

The FastAPI equivalent separates concerns and leverages dependency injection:

# api/v1/orders.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from database import get_db
from schemas.order import OrderCreate, OrderResponse
from services.order_service import OrderService
from core.security import get_current_user
from models.user import User

router = APIRouter(prefix="/orders", tags=["orders"])

@router.get("/", response_model=List[OrderResponse])
async def list_orders(
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
    skip: int = 0,
    limit: int = 100
):
    """Retrieve orders for the authenticated user."""
    service = OrderService(db)
    orders = service.get_user_orders(
        user_id=current_user.id,
        skip=skip,
        limit=limit
    )
    return orders

@router.post("/", response_model=OrderResponse, status_code=status.HTTP_201_CREATED)
async def create_order(
    order_data: OrderCreate,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db)
):
    """Create a new order for the authenticated user."""
    service = OrderService(db)
    try:
        order = service.create_order(user_id=current_user.id, order_data=order_data)
        return order
    except ValueError as e:
        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))

Notice several improvements: The response_model parameter automatically serializes output and generates OpenAPI documentation. Dependencies like get_current_user and get_db are injected declaratively. Query parameters (skip, limit) are validated from type annotations. Business logic is delegated to a service layer rather than embedded in the view.

Step 7: Build the Service Layer for Business Logic

Extracting business logic into services keeps endpoints thin and testable. The service layer becomes the single place where complex operations, validations, and orchestration happen:

# services/order_service.py
from sqlalchemy.orm import Session
from models.order import Order
from schemas.order import OrderCreate
from typing import List

class OrderService:
    def __init__(self, db: Session):
        self.db = db
    
    def get_user_orders(self, user_id: int, skip: int = 0, limit: int = 100) -> List[Order]:
        return (
            self.db.query(Order)
            .filter(Order.user_id == user_id)
            .order_by(Order.created_at.desc())
            .offset(skip)
            .limit(limit)
            .all()
        )
    
    def create_order(self, user_id: int, order_data: OrderCreate):
        # Calculate total from items
        total_amount = sum(
            item.quantity * item.unit_price 
            for item in order_data.items
        )
        
        order = Order(
            user_id=user_id,
            status=order_data.status,
            total_amount=total_amount,
            items=[item.model_dump() for item in order_data.items]
        )
        
        self.db.add(order)
        self.db.commit()
        self.db.refresh(order)
        return order
    
    def update_order_status(self, order_id: int, user_id: int, new_status: str):
        order = (
            self.db.query(Order)
            .filter(Order.id == order_id, Order.user_id == user_id)
            .first()
        )
        if not order:
            raise ValueError(f"Order {order_id} not found")
        
        valid_transitions = {
            'pending': ['shipped'],
            'shipped': ['delivered'],
            'delivered': []
        }
        
        if new_status not in valid_transitions.get(order.status, []):
            raise ValueError(
                f"Cannot transition from {order.status} to {new_status}"
            )
        
        order.status = new_status
        self.db.commit()
        self.db.refresh(order)
        return order

This pattern makes unit testing straightforward because you can mock the database session and test business rules in isolation without HTTP concerns.

Step 8: Migrate Authentication and Authorization

Django's authentication system (sessions, contrib.auth, permissions) must be replaced with a token-based approach in FastAPI. JWT (JSON Web Tokens) is the most common choice.

# core/security.py
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from database import get_db
from models.user import User
from config import settings

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")

def verify_password(plain_password: str, hashed_password: str) -> bool:
    return pwd_context.verify(plain_password, hashed_password)

def get_password_hash(password: str) -> str:
    return pwd_context.hash(password)

def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
    to_encode = data.copy()
    if expires_delta:
        expire = datetime.now(timezone.utc) + expires_delta
    else:
        expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
    to_encode.update({"exp": expire})
    encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
    return encoded_jwt

async def get_current_user(
    token: str = Depends(oauth2_scheme),
    db: Session = Depends(get_db)
) -> User:
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
        username: str = payload.get("sub")
        if username is None:
            raise credentials_exception
    except JWTError:
        raise credentials_exception
    
    user = db.query(User).filter(User.username == username).first()
    if user is None:
        raise credentials_exception
    if not user.is_active:
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user")
    return user

The authentication endpoint itself is a simple path operation:

# api/v1/auth.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from database import get_db
from schemas.user import UserLogin, TokenResponse
from core.security import verify_password, create_access_token, get_password_hash
from models.user import User

router = APIRouter(prefix="/auth", tags=["authentication"])

@router.post("/login", response_model=TokenResponse)
async def login(login_data: UserLogin, db: Session = Depends(get_db)):
    user = db.query(User).filter(User.username == login_data.username).first()
    if not user or not verify_password(login_data.password, user.password_hash):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password"
        )
    
    access_token = create_access_token(data={"sub": user.username})
    return TokenResponse(access_token=access_token)

For permission-based authorization (equivalent to Django's @permission_classes), create dependency functions:

# api/dependencies.py
from fastapi import Depends, HTTPException, status
from core.security import get_current_user
from models.user import User

def require_admin(current_user: User = Depends(get_current_user)):
    if not current_user.is_superuser:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Admin privileges required"
        )
    return current_user

def require_staff(current_user: User = Depends(get_current_user)):
    if not current_user.is_staff and not current_user.is_superuser:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Staff privileges required"
        )
    return current_user

Use these in endpoints like Depends(require_admin). This composable approach is more flexible than Django's decorator-based permissions.

Step 9: Migrate Middleware and Custom Decorators

Django middleware that processes every request (like audit logging, rate limiting, or CORS headers) translates to FastAPI middleware with a slightly different pattern:

# core/middleware.py
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
import time
import logging

logger = logging.getLogger("audit")

class AuditLogMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        start_time = time.time()
        
        # Log the incoming request
        logger.info(
            f"Request: {request.method} {request.url.path} "
            f"from {request.client.host}"
        )
        
        response = await call_next(request)
        
        # Log the response
        process_time = time.time() - start_time
        logger.info(
            f"Response: {response.status_code} "
            f"for {request.method} {request.url.path} "
            f"took {process_time:.4f}s"
        )
        
        response.headers["X-Process-Time"] = str(process_time)
        return response

Register middleware in your main application:

# main.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from core.middleware import AuditLogMiddleware
from api.v1.router import api_router

app = FastAPI(
    title="My API",
    description="Migrated from Django to FastAPI",
    version="1.0.0"
)

# Built-in middleware for CORS
app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://frontend.example.com"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# Custom middleware
app.add_middleware(AuditLogMiddleware)

# Include API routes
app.include_router(api_router, prefix="/api/v1")

Step 10: Handle Django Signals with FastAPI Events

Django signals (pre_save, post_save, etc.) don't exist in FastAPI/SQLAlchemy. Replace them with SQLAlchemy event listeners or FastAPI's startup/shutdown event hooks:

# models/events.py
from sqlalchemy import event
from models.user import User
import logging

logger = logging.getLogger("user_events")

@event.listens_for(User, "before_insert")
def before_user_insert(mapper, connection, target):
    logger.info(f"Creating new user: {target.username}")
    # Perform pre-creation logic like sending welcome email

@event.listens_for(User, "after_update")
def after_user_update(mapper, connection, target):
    logger.info(f"User updated: {target.username}")
    # Invalidate cache, send notifications, etc.

For application-level startup tasks (like loading reference data or warming caches), use FastAPI's lifespan context manager:

# main.py (updated)
from contextlib import asynccontextmanager
from database import engine, Base

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Startup: create tables if needed (for dev), warm caches
    Base.metadata.create_all(bind=engine)
    # Load reference data, initialize connections, etc.
    yield
    # Shutdown: clean up resources
    engine.dispose()

app = FastAPI(lifespan=lifespan)

Step 11: Migrate Management Commands

Django management commands become standalone scripts or CLI tools using libraries like Typer (which shares ancestry with FastAPI):

# scripts/seed_data.py
import typer
from sqlalchemy.orm import Session
from database import SessionLocal
from models.user import User
from core.security import get_password_hash

app = typer.Typer()

@app.command()
def seed_users(count: int = 10):
    """Seed the database with test users."""
    db = SessionLocal()
    try:
        for i in range(count):
            user = User(
                username=f"user_{i}",
                email=f"user_{i}@example.com",
                password_hash=get_password_hash("testpass123"),
                first_name=f"Test{i}",
                is_active=True
            )
            db.add(user)
        db.commit()
        print(f"Created {count} test users")
    finally:
        db.close()

if __name__ == "__main__":
    app()

Step 12: Comprehensive Testing Strategy

FastAPI provides a TestClient based on httpx that makes API testing clean and fast. Replace Django's TestCase and APIClient with pytest and TestClient:

# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from database import Base, get_db
from main import app

SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

def override_get_db():
    db = TestingSessionLocal()
    try:
        yield db
    finally:
        db.close()

@pytest.fixture
def client():
    Base.metadata.create_all(bind=engine)
    app.dependency_overrides[get_db] = override_get_db
    with TestClient(app) as test_client:
        yield test_client
    Base.metadata.drop_all(bind=engine)

@pytest.fixture
def auth_headers(client):
    # Create a test user and get token
    client.post("/api/v1/users/register", json={
        "username": "testuser",
        "email": "test@test.com",
        "password": "securepass123"
    })
    response = client.post("/api/v1/auth/login", json={
        "username": "testuser",
        "password": "securepass123"
    })
    token = response.json()["access_token"]
    return {"Authorization": f"Bearer {token}"}

Test endpoints with clean assertions:

# tests/test_orders.py
def test_create_order_success(client, auth_headers):
    order_data = {
        "items": [
            {"product_name": "Widget", "quantity": 2, "unit_price": 19.99},
            {"product_name": "Gadget", "quantity": 1, "unit_price": 49.99}
        ],
        "status": "pending"
    }
    response = client.post("/api/v1/orders/", json=order_data, headers=auth_headers)
    assert response.status_code == 201
    data = response.json()
    assert data["total_amount"] == 89.97
    assert len(data["items"]) == 2
    assert data["user_id"] is not None

def test_list_orders_empty(client, auth_headers):
    response = client.get("/api/v1/orders/", headers=auth_headers)
    assert response.status_code == 200
    assert response.json() == []

def test_unauthorized_access(client):
    response = client.get("/api/v1/orders/")
    assert response.status_code == 401

Best Practices for a Smooth Migration

Run Both Frameworks in Parallel Initially

Don't attempt a big-bang rewrite. Configure your reverse proxy (Nginx, Traefik) to route some paths to Django and others to FastAPI based on URL prefixes. For example, keep /admin and /legacy/ on Django while new API endpoints under /api/v2/ go to FastAPI. This allows gradual migration and immediate rollback if issues arise.

Keep the Database Schema Compatible

If both frameworks share the same database during transition, ensure your SQLAlchemy models map exactly to the existing Django-created tables. Use the __tablename__ attribute to match Django's naming convention (typically appname_modelname). Run alembic revision --autogenerate to detect any discrepancies before they cause production errors.

Extract Business Logic Before Migrating Views

Before rewriting views, extract core business rules into plain Python functions or classes that don't depend on Django's request/response cycle. These pure functions can be tested independently and then imported into either Django views or FastAPI endpoints during the transition period.

Use Alembic for Migrations Instead of Django Migrations

Once you commit to FastAPI, use Alembic for schema migrations. Initialize it with:

alembic init alembic
# Configure alembic/env.py to import your Base and use your engine

This decouples your schema management from Django entirely and gives you more granular control over migration generation.

Implement Comprehensive Logging and Monitoring

During migration, unusual patterns may emerge. Add structured logging to both frameworks and monitor error rates, response times, and resource usage. FastAPI integrates easily with OpenTelemetry for distributed tracing, which helps identify bottlenecks in the new architecture.

Document the New API Thoroughly

FastAPI generates OpenAPI documentation automatically, but you should enhance it with detailed descriptions, examples, and error responses. Use the summary, description, and response_description parameters on path operations, and add Field(examples=...) to Pydantic models for richer generated docs.

Handle File Uploads Properly

Django's FileField and ImageField have no direct equivalent. In FastAPI, use UploadFile from fastapi and store files in cloud storage (S3, GCS) rather than the local filesystem:

from fastapi import UploadFile, File

@router.post("/upload")
async def upload_document(
    file: UploadFile = File(...),
    current_user: User = Depends(get_current_user)
):
    contents = await file.read()
    # Upload to S3 or process accordingly
    # Store metadata in database, not file path

Plan for Django Admin Replacement

Django's admin interface has no equivalent in FastAPI. Options include building a custom admin frontend with React Admin or similar frameworks that consume your FastAPI endpoints, using SQLAdmin (a lightweight admin interface for SQLAlchemy), or keeping a minimal Django instance running solely for admin purposes with a shared database.

Conclusion

Migrating from Django to FastAPI is

πŸš€ Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started β€” $23.99/mo
← Back to all articles