FastAPI: The Framework That Convinced Python Developers That Decorators Equal Architecture
After watching too many projects wish they'd just used Django when hitting production requirements, I need to share some observations. FastAPI excels at simple APIs and microservices, but can struggle when you need the full feature set of a mature web application.
To be clear: FastAPI is genuinely good at what it's designed for - building fast, modern APIs with excellent developer experience. The problems arise when teams try to use it for full-stack applications that need user management, admin interfaces, and all the other "boring" features that make software actually useful.
The Initial Simplicity vs Production Reality
Every FastAPI tutorial starts the same way:
python
1 # FastAPI's initial simplicity:2 from fastapi import FastAPI3 4 app = FastAPI()5 6 @app.get("/users/{user_id}")7 async def get_user(user_id: int):8 return {"user_id": user_id, "name": "John"}9 10 # Clean code, automatic docs, type hints - very appealing!
But here's what that endpoint looks like 6 months into a real project:
python
1 from fastapi import FastAPI, Depends, HTTPException, Security, status2 from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm3 from sqlalchemy.orm import Session4 from typing import Optional, List, Dict, Any5 from datetime import datetime, timedelta6 from jose import JWTError, jwt7 from passlib.context import CryptContext8 from pydantic import BaseModel, validator, Field9 from app.core.config import settings10 from app.db.session import get_db11 from app.models.user import User12 from app.schemas.user import UserCreate, UserUpdate, UserInDB13 from app.core.security import get_password_hash, verify_password14 from app.core.auth import create_access_token15 from app.api.deps import get_current_user, get_current_active_superuser16 from app.crud.base import CRUDBase17 from app.utils.email import send_email18 import logging19 from app.core.logging import logger20 from app.core.cache import cache21 from app.core.ratelimit import limiter22 from app.middleware.correlation import correlation_id23 from app.core.exceptions import UserNotFound, InvalidCredentials24 from app.core.permissions import has_permission25 26 # Your endpoint now requires extensive dependency injection setup
The Dependency Injection Problem
A common pattern I've observed - FastAPI's DI system starts elegant but can become complex to debug:
python
1 # What you think you're getting:2 async def get_current_user(3 db: Session = Depends(get_db),4 token: str = Depends(oauth2_scheme),5 settings: Settings = Depends(get_settings),6 cache: Redis = Depends(get_cache),7 ) -> User:8 # 50 lines of boilerplate auth logic9 # Check JWT, validate, query database, handle cache10 # All manually implemented11 pass12 13 # Now every endpoint needs:14 @app.get("/protected")15 async def protected_route(16 current_user: User = Depends(get_current_user),17 db: Session = Depends(get_db),18 cache: Redis = Depends(get_cache),19 background_tasks: BackgroundTasks = BackgroundTasks(),20 request: Request = Request,21 x_correlation_id: Optional[str] = Header(None),22 ):23 # Your actual logic is 10% of the function24 pass25 26 # Meanwhile in Django:27 @api_view(['GET'])28 @permission_classes([IsAuthenticated])29 def protected_route(request):30 # Just works. User is at request.user31 pass
"But FastAPI Is So Fast!"
FastAPI's performance claims need a reality check.
⚠️
FastAPI's benchmarks show impressive throughput - it can handle thousands of requests per second. But here's the thing: throughput isn't what your users experience. They experience latency.
And latency is dominated by everything EXCEPT your web framework.
FastAPI does give you excellent throughput. You might handle 30,000 requests/second compared to Django's 3,000. That's a 10x improvement! But each of those requests still takes the same time:
- •Database queries - That N+1 query problem adds 150ms
- •External API calls - Stripe still takes 200ms to respond
- •Business logic - That algorithm is still O(n³)
- •Serialization - Converting those 10,000 objects still takes 100ms
Your users don't care that you can handle more concurrent requests. They care that their request takes 500ms to complete.
FastAPI optimizes that tiny slice you can't even see. Yes, you get better throughput - you can serve more of these slow requests concurrently. But each individual user still waits the same 500ms.
The Async Complexity Challenge
And here's what I've learned the hard way about FastAPI's async promises:
💡
Actually, Django has had async views since 3.1 (2020). The difference? Django added async support incrementally, focusing on use cases where it provides clear benefits.
python
1 # Django async view (when you actually need it)2 async def my_view(request):3 # Make concurrent external API calls4 results = await asyncio.gather(5 fetch_payment_data(),6 fetch_shipping_data(),7 fetch_inventory_data()8 )9 return JsonResponse({"results": results})
Django didn't force you to make everything async and deal with the impedance mismatch between async code and your sync database.
python
1 # What FastAPI developers think they're doing:2 @app.get("/fetch-data")3 async def fetch_data():4 # "It's async so it must be fast!"5 result = await some_database_call() # Still blocks on I/O6 7 # But wait, your ORM isn't actually async8 # So you're using sync SQLAlchemy in an async function9 # Congrats, you've made it slower10 11 # The reality I keep encountering:12 async def complex_endpoint():13 # Async HTTP call - good!14 async with httpx.AsyncClient() as client:15 response = await client.get("https://api.example.com")16 17 # But your database is sync - adds overhead18 db_result = await run_in_threadpool(sync_database_call)19 20 # And your cache library is sync - more overhead21 cache_result = await run_in_threadpool(redis_client.get, "key")22 23 # You've added threadpool overhead to everything24 # Performance is now WORSE than sync Django
The Missing Batteries Problem
Here's what FastAPI provides out of the box vs what production apps typically need:
python
1 # FastAPI out of the box:2 fastapi_features = {3 "routing": "Basic",4 "validation": "Pydantic (actually nice)",5 "docs": "Auto-generated (until you need flexibility)",6 "everything_else": "Stack Overflow and prayer"7 }8 9 # What Django gives you (that actually works in production):10 django_batteries = {11 "admin_interface": "Best in the industry, customizable AF",12 "authentication": "Battle-tested for 20 years",13 "permissions": "Object-level, row-level, whatever-level",14 "database_migrations": "Automatic with manage.py",15 "user_management": "AbstractUser + extend as needed",16 "email_sending": "Built-in with multiple backends",17 "background_tasks": "Celery integration + django-q",18 "caching": "Pluggable backends, works out of box",19 "sessions": "Secure by default",20 "middleware": "Tons built-in + easy to add",21 "testing": "TestCase with fixtures, mocks, client",22 "static_files": "WhiteNoise, S3, whatever you want",23 "templates": "If you need them",24 "forms": "Still the best form handling ever",25 "CSRF": "Enabled by default (security first)",26 "rate_limiting": "django-ratelimit, one decorator",27 "API_docs": "drf-spectacular > FastAPI docs"28 }
💡
"I don't need all those features," you say. Cool. Django doesn't force you to use them. But when you inevitably need user management at 2 AM on a Sunday, it's there.
With FastAPI, you'll be reading blog posts about "How to implement user registration with FastAPI" and copying code from 2019 that probably has security vulnerabilities.
Django gives you a full toolbox. FastAPI gives you a hammer and wishes you luck building a house.
The Project Structure Chaos
Every FastAPI project I've seen has a completely different structure:
# Project 1:
main.py # thousands of lines in one file
# Project 2:
app/
├── api/
│ └── v1/
│ └── endpoints/
│ └── [20 nested folders]
# Project 3:
[Completely different pattern]
# Django projects:
# They all look the same because conventions exist
SQLAlchemy: When Your "ORM" Is Actually a Database Construction Kit
The elephant in the room that FastAPI developers don't want to admit: SQLAlchemy isn't an ORM. It's a "database toolkit." That means you're about to write a lot of boilerplate.
⚠️
SQLAlchemy gives you two APIs:
- •Core: Raw SQL construction kit for masochists
- •ORM: An object-relational mapper that makes you appreciate actual ORMs
FastAPI developers usually use the ORM, then spend months implementing features that Django's ORM has had since 2008.
Here's what using SQLAlchemy actually looks like in a FastAPI project:
python
1 # First, define your database connection2 from sqlalchemy import create_engine3 from sqlalchemy.ext.declarative import declarative_base4 from sqlalchemy.orm import sessionmaker5 6 SQLALCHEMY_DATABASE_URL = "postgresql://user:password@localhost/dbname"7 engine = create_engine(SQLALCHEMY_DATABASE_URL)8 SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)9 Base = declarative_base()10 11 # Then, create a dependency to get DB sessions12 def get_db():13 db = SessionLocal()14 try:15 yield db16 finally:17 db.close()18 19 # Define your model (Layer 1)20 class User(Base):21 __tablename__ = "users"22 id = Column(Integer, primary_key=True, index=True)23 email = Column(String, unique=True, index=True)24 is_active = Column(Boolean, default=True)25 26 # But wait! You need Pydantic models too (Layer 2)27 class UserBase(BaseModel):28 email: str29 30 class UserCreate(UserBase):31 password: str32 33 class User(UserBase):34 id: int35 is_active: bool36 class Config:37 orm_mode = True38 39 # Now create a CRUD class (Layer 3)40 class CRUDUser:41 def get(self, db: Session, user_id: int):42 return db.query(User).filter(User.id == user_id).first()43 44 def create(self, db: Session, obj_in: UserCreate):45 # Manual password hashing46 # Manual object creation47 # Manual everything48 pass49 50 # Finally, use it in your endpoint (Layer 4)51 @app.post("/users/", response_model=User)52 def create_user(53 user: UserCreate,54 db: Session = Depends(get_db),55 crud: CRUDUser = Depends()56 ):57 return crud.create(db=db, obj_in=user)
Count the layers of abstraction:
- •SQLAlchemy models
- •Pydantic schemas
- •CRUD classes
- •Dependency injection
- •Manual session management
- •Manual transaction handling
Now here's the same thing in Django:
python
1 # Django model (that's it, you're done)2 class User(models.Model):3 email = models.EmailField(unique=True)4 is_active = models.BooleanField(default=True)5 6 # Django view7 @api_view(['POST'])8 def create_user(request):9 serializer = UserSerializer(data=request.data)10 if serializer.is_valid():11 serializer.save() # Handles everything12 return Response(serializer.data)13 return Response(serializer.errors)14 15 # Or with viewsets (even simpler)16 class UserViewSet(ModelViewSet):17 queryset = User.objects.all()18 serializer_class = UserSerializer19 # Done. Full CRUD. With permissions. And filtering.
💡
Sure, SQLAlchemy lets you write complex queries. So does Django's ORM, which can handle 99% of use cases and has escape hatches for the 1%:
python
1 # Django complex query2 users = User.objects.filter(3 Q(email__endswith='@gmail.com') | Q(is_staff=True)4 ).select_related('profile').prefetch_related('orders').annotate(5 total_spent=Sum('orders__amount')6 ).filter(total_spent__gt=1000)7 8 # Still too simple? Use raw SQL9 users = User.objects.raw("SELECT * FROM users WHERE complex_stuff")
The difference is Django makes the common case easy and the complex case possible. SQLAlchemy makes everything equally complex.
The Schema Duplication Hell
And this brings us to the worst part - the endless duplication. But it's not just about duplication, it's about where your business logic lives:
python
1 # You define your model in SQLAlchemy2 class User(Base):3 __tablename__ = "users"4 id = Column(Integer, primary_key=True)5 email = Column(String, unique=True)6 username = Column(String, unique=True)7 # ... 10 more fields8 9 # Then in Pydantic schemas:10 class UserBase(BaseModel):11 email: EmailStr12 username: str13 14 class UserCreate(UserBase):15 password: str16 17 class UserUpdate(UserBase):18 password: Optional[str] = None19 20 class UserInDB(UserBase):21 id: int22 created_at: datetime23 24 class User(UserInDB):25 pass26 27 # That's 5-6 classes for ONE model28 # Django: Just define your model once
But here's what FastAPI evangelists get wrong: "DRF has too many abstraction layers" isn't a bug - it's a feature.
✨
FastAPI developers love to complain about DRF's "complexity":
- •"Why do I need Models AND Serializers AND ViewSets?"
- •"It's just CRUD, why so many layers?"
- •"FastAPI is so much simpler!"
Then they hit production and realize those layers exist for a reason. Real applications aren't embarrassingly simple CRUD.
python
1 # Django REST Framework - those "unnecessary" layers in action:2 class UserSerializer(serializers.ModelSerializer):3 # Layer 1: Serialization handles more than just JSON conversion4 full_name = serializers.SerializerMethodField()5 subscription_status = serializers.SerializerMethodField()6 7 class Meta:8 model = User9 fields = ['id', 'email', 'full_name', 'subscription_status']10 11 def get_subscription_status(self, obj):12 # Complex business logic with caching13 return cache.get_or_set(14 f'sub_status_{obj.id}',15 lambda: calculate_subscription_status(obj),16 timeout=30017 )18 19 def validate_email(self, value):20 # Validation that needs database access21 if User.objects.filter(email__iexact=value).exists():22 raise serializers.ValidationError("Email already in use")23 return value.lower()24 25 def create(self, validated_data):26 # Transaction boundaries are clear27 with transaction.atomic():28 user = super().create(validated_data)29 create_related_records(user)30 send_notifications(user)31 return user32 33 class UserViewSet(viewsets.ModelViewSet):34 # Layer 2: ViewSets handle request logic35 serializer_class = UserSerializer36 queryset = User.objects.all()37 38 def get_queryset(self):39 # Complex query optimization happens here40 qs = super().get_queryset()41 if self.action == 'list':42 # Prevent N+1 queries43 qs = qs.select_related('profile').prefetch_related('subscriptions')44 return qs45 46 def get_serializer_class(self):47 # Different serializers for different actions48 if self.action == 'list':49 return UserListSerializer # Minimal fields50 elif self.request.user.is_staff:51 return UserAdminSerializer # All fields52 return UserSerializer # Standard fields53 54 # FastAPI: Everything jammed into one place55 @app.post("/users")56 async def create_user(57 user_data: UserCreate,58 db: Session = Depends(get_db),59 current_user: User = Depends(get_current_user)60 ):61 # Where do I put:62 # - Complex validation that needs DB access?63 # - Different response formats for different users?64 # - Query optimization?65 # - Transaction boundaries?66 # - Computed fields with caching?67 # Answer: ¯\_(ツ)_/¯ Figure it out yourself
Those DRF abstraction layers give you:
- •Clear separation of concerns (HTTP handling vs business logic vs data access)
- •Reusable components (same serializer for API and admin)
- •Predictable patterns (every dev knows where to look)
- •Performance optimization hooks (queryset customization)
- •Transaction boundaries (in the serializer, not the view)
- •Testability (test each layer independently)
With FastAPI, you'll eventually build these same layers yourself, except:
- •They'll be inconsistent across your codebase
- •Every developer will do it differently
- •You'll have no documentation
- •New team members will hate you
The "Best of Breed" Philosophy Challenges
FastAPI's philosophy is "we don't include batteries, just use the best tool for each job." Sounds great until you realize their "best" tools often require more work than Django's built-ins for API development.
Authentication: FastAPI + python-jose vs Django REST Framework
python
1 # FastAPI "recommended" auth setup:2 # 1. Install python-jose, passlib, python-multipart3 # 2. Write extensive JWT boilerplate4 # 3. Handle password hashing manually5 # 4. Implement token refresh manually6 # 5. Debug JWT decode errors at 3 AM7 8 from jose import JWTError, jwt9 from passlib.context import CryptContext10 from datetime import datetime, timedelta11 12 pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")13 14 def verify_password(plain_password, hashed_password):15 return pwd_context.verify(plain_password, hashed_password)16 17 def get_password_hash(password):18 return pwd_context.hash(password)19 20 def create_access_token(data: dict):21 to_encode = data.copy()22 expire = datetime.utcnow() + timedelta(minutes=15)23 to_encode.update({"exp": expire})24 return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)25 26 # ... extensive auth implementation27 28 # Django REST Framework:29 # pip install djangorestframework-simplejwt30 REST_FRAMEWORK = {31 'DEFAULT_AUTHENTICATION_CLASSES': [32 'rest_framework_simplejwt.authentication.JWTAuthentication',33 ]34 }35 # Done. With refresh tokens, blacklisting, sliding tokens, and token verify endpoints.
API Permissions: Roll Your Own vs DRF
python
1 # FastAPI permission system:2 def has_permission(user: User, resource: str, action: str):3 # Implement entire RBAC system from scratch4 # Handle object-level permissions manually5 # Build permission checking decorators6 # Create permission classes7 # ... significant code later8 9 @app.get("/api/documents/{doc_id}")10 async def get_document(11 doc_id: int,12 current_user: User = Depends(get_current_user),13 db: Session = Depends(get_db)14 ):15 # Manual permission check16 doc = db.query(Document).filter(Document.id == doc_id).first()17 if not has_permission(current_user, doc, "read"):18 raise HTTPException(status_code=403)19 return doc20 21 # Django REST Framework:22 class IsOwnerOrReadOnly(permissions.BasePermission):23 def has_object_permission(self, request, view, obj):24 # Read permissions for any request25 if request.method in permissions.SAFE_METHODS:26 return True27 # Write permissions only for owner28 return obj.owner == request.user29 30 class DocumentViewSet(viewsets.ModelViewSet):31 permission_classes = [IsOwnerOrReadOnly]32 # Automatic permission checking on all CRUD operations
API Filtering, Searching, and Ordering: DIY vs django-filter
python
1 # FastAPI filtering:2 @app.get("/api/users")3 async def list_users(4 skip: int = 0,5 limit: int = 100,6 name: Optional[str] = None,7 email: Optional[str] = None,8 created_after: Optional[datetime] = None,9 created_before: Optional[datetime] = None,10 is_active: Optional[bool] = None,11 ordering: Optional[str] = None,12 db: Session = Depends(get_db)13 ):14 query = db.query(User)15 16 # Manual filtering (tedious)17 if name:18 query = query.filter(User.name.contains(name))19 if email:20 query = query.filter(User.email.contains(email))21 if created_after:22 query = query.filter(User.created_at >= created_after)23 if created_before:24 query = query.filter(User.created_at <= created_before)25 if is_active is not None:26 query = query.filter(User.is_active == is_active)27 28 # Manual ordering29 if ordering:30 if ordering.startswith("-"):31 query = query.order_by(desc(getattr(User, ordering[1:])))32 else:33 query = query.order_by(getattr(User, ordering))34 35 return query.offset(skip).limit(limit).all()36 37 # Django REST Framework + django-filter:38 class UserViewSet(viewsets.ModelViewSet):39 queryset = User.objects.all()40 serializer_class = UserSerializer41 filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]42 filterset_fields = ['name', 'email', 'created_at', 'is_active']43 search_fields = ['name', 'email']44 ordering_fields = '__all__'45 # Done. With validation, type conversion, and everything else.
API Throttling: Roll Your Own vs DRF
python
1 # FastAPI rate limiting:2 # Option 1: slowapi (third-party, hope it's maintained)3 from slowapi import Limiter, _rate_limit_exceeded_handler4 from slowapi.util import get_remote_address5 6 limiter = Limiter(key_func=get_remote_address)7 app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)8 9 @app.get("/api/endpoint")10 @limiter.limit("5/minute") # Hope this works with async11 async def limited_endpoint(request: Request):12 return {"msg": "Hello"}13 14 # Option 2: Build your own with Redis (good luck)15 16 # Django REST Framework:17 REST_FRAMEWORK = {18 'DEFAULT_THROTTLE_CLASSES': [19 'rest_framework.throttling.AnonRateThrottle',20 'rest_framework.throttling.UserRateThrottle'21 ],22 'DEFAULT_THROTTLE_RATES': {23 'anon': '100/day',24 'user': '1000/day'25 }26 }27 28 # Or per-view:29 class MyViewSet(viewsets.ModelViewSet):30 throttle_classes = [UserRateThrottle]31 # Automatic rate limiting with multiple backends
Pagination: Build Your Own vs DRF
python
1 # FastAPI pagination:2 def paginate(query, skip: int = 0, limit: int = 100):3 # Build your own pagination logic4 # Handle edge cases5 # Create response format6 # Add metadata7 total = query.count()8 items = query.offset(skip).limit(limit).all()9 10 return {11 "items": items,12 "total": total,13 "skip": skip,14 "limit": limit,15 "has_next": skip + limit < total,16 # ... more metadata you have to calculate17 }18 19 # Django REST Framework:20 class StandardResultsSetPagination(PageNumberPagination):21 page_size = 10022 page_size_query_param = 'page_size'23 max_page_size = 100024 25 class MyViewSet(viewsets.ModelViewSet):26 pagination_class = StandardResultsSetPagination27 # Automatic pagination with links, counts, and metadata
API Versioning: ??? vs DRF
python
1 # FastAPI versioning:2 # Option 1: URL path versioning (manual)3 app_v1 = FastAPI()4 app_v2 = FastAPI()5 6 @app_v1.get("/users")7 def get_users_v1():8 pass9 10 @app_v2.get("/users")11 def get_users_v2():12 pass13 14 app.mount("/api/v1", app_v1)15 app.mount("/api/v2", app_v2)16 # Good luck maintaining this17 18 # Django REST Framework:19 REST_FRAMEWORK = {20 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.NamespaceVersioning',21 # Or URLPathVersioning, QueryParameterVersioning, HeaderVersioning, etc.22 }23 24 class MyViewSet(viewsets.ModelViewSet):25 def list(self, request):26 if request.version == 'v1':27 # v1 logic28 elif request.version == 'v2':29 # v2 logic
💡
Every time you need standard API functionality in FastAPI, you either:
- •Install a third-party library (that may or may not be maintained)
- •Build it yourself from scratch
- •Copy-paste from Stack Overflow and hope it works
With Django REST Framework, it's built-in, tested, documented, and works.
The Testing Nightmare
And don't even get me started on testing. Here's what a real test looks like in both frameworks:
python
1 # FastAPI testing setup:2 import pytest3 from fastapi.testclient import TestClient4 from sqlalchemy import create_engine5 from sqlalchemy.orm import sessionmaker6 from app.main import app7 from app.database import Base, get_db8 from app.models import User9 10 # Set up test database11 SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db"12 engine = create_engine(SQLALCHEMY_DATABASE_URL)13 TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)14 15 def override_get_db():16 try:17 db = TestingSessionLocal()18 yield db19 finally:20 db.close()21 22 app.dependency_overrides[get_db] = override_get_db23 client = TestClient(app)24 25 @pytest.fixture26 def test_db():27 Base.metadata.create_all(bind=engine)28 yield29 Base.metadata.drop_all(bind=engine)30 31 def test_create_user(test_db):32 response = client.post(33 "/users/",34 json={"email": "test@example.com", "password": "secret"}35 )36 assert response.status_code == 20037 38 # Django equivalent:39 from django.test import TestCase40 from django.contrib.auth.models import User41 42 class UserTestCase(TestCase):43 def test_create_user(self):44 response = self.client.post(45 "/users/",46 {"email": "test@example.com", "password": "secret"},47 content_type="application/json"48 )49 self.assertEqual(response.status_code, 200)50 # Database automatically rolled back after test
The Real Production Code
Here's what everyone ends up writing in every FastAPI project:
python
1 # Because FastAPI has no service layer, you create your own:2 from typing import Optional, List3 from datetime import datetime4 from sqlalchemy.orm import Session5 from passlib.context import CryptContext6 from app.core.cache import cache_client7 import logging8 9 pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")10 logger = logging.getLogger(__name__)11 12 class UserService:13 def __init__(self, db: Session):14 self.db = db15 16 def create_user(self, user_data: UserCreate) -> User:17 # Manual transaction handling18 try:19 # Manual password hashing20 hashed_password = pwd_context.hash(user_data.password)21 22 # Manual validation beyond Pydantic23 existing = self.db.query(User).filter(24 User.email == user_data.email.lower()25 ).first()26 if existing:27 raise ValueError("Email already registered")28 29 # Create user instance manually30 db_user = User(31 email=user_data.email.lower(),32 username=user_data.username,33 hashed_password=hashed_password,34 is_active=True,35 is_verified=False,36 created_at=datetime.utcnow(),37 updated_at=datetime.utcnow()38 )39 40 # Manual save41 self.db.add(db_user)42 self.db.commit()43 self.db.refresh(db_user)44 45 # Manual cache invalidation46 cache_client.delete(f"user:{db_user.id}")47 cache_client.delete(f"user:email:{db_user.email}")48 49 # Manual signals/events (no built-in system)50 self._send_welcome_email(db_user)51 self._create_user_profile(db_user)52 self._log_user_creation(db_user)53 self._generate_auth_token(db_user) # Need to manually handle JWT tokens too54 55 # Manual audit logging56 logger.info(f"User created: {db_user.email}")57 58 return db_user59 60 except Exception as e:61 # Manual rollback62 self.db.rollback()63 logger.error(f"User creation failed: {str(e)}")64 raise65 66 def _send_welcome_email(self, user: User):67 # Manual email implementation with SendGrid68 import os69 from sendgrid import SendGridAPIClient70 from sendgrid.helpers.mail import Mail71 72 try:73 if os.getenv("ENVIRONMENT") == "local":74 # Local testing - just log to console75 logger.info(f"[LOCAL EMAIL] To: {user.email}")76 logger.info(f"[LOCAL EMAIL] Subject: Welcome to our platform!")77 logger.info(f"[LOCAL EMAIL] Welcome message for {user.username}")78 return79 80 # Production - use SendGrid81 sg = SendGridAPIClient(os.getenv('SENDGRID_API_KEY'))82 83 # Build email manually84 message = Mail(85 from_email=('noreply@example.com', 'Your App'),86 to_emails=user.email,87 subject='Welcome to our platform!',88 html_content=f'''89 Welcome {user.username}!90 Thanks for signing up. We're excited to have you on board!91 You can now access all features of our platform.92 '''93 )94 95 # Add tracking settings manually96 message.tracking_settings = {97 "click_tracking": {"enable": True},98 "open_tracking": {"enable": True}99 }100 101 response = sg.send(message)102 103 if response.status_code != 202:104 raise Exception(f"SendGrid returned {response.status_code}")105 106 logger.info(f"Welcome email sent to {user.email} via SendGrid")107 108 except Exception as e:109 # Manual error handling - should we retry? Queue it? Alert someone?110 logger.error(f"Failed to send welcome email: {str(e)}")111 112 # Should probably add to a retry queue here113 # But that means setting up Celery or similar...114 # For now, just track the failure115 self._track_email_failure(user.id, "welcome_email", str(e))116 117 def _track_email_failure(self, user_id: int, email_type: str, error: str):118 # Track failed emails for retry119 from app.models import FailedEmail120 121 failed = FailedEmail(122 user_id=user_id,123 email_type=email_type,124 error_message=error,125 retry_count=0,126 created_at=datetime.utcnow()127 )128 self.db.add(failed)129 self.db.commit()130 131 def _create_user_profile(self, user: User):132 # Create related profile record manually133 from app.models import UserProfile134 135 profile = UserProfile(136 user_id=user.id,137 bio="",138 avatar_url=f"https://gravatar.com/avatar/{hash(user.email)}?d=identicon",139 preferences={140 "email_notifications": True,141 "newsletter": False,142 "theme": "light"143 },144 created_at=datetime.utcnow()145 )146 self.db.add(profile)147 self.db.commit()148 149 # Clear profile cache150 cache_client.delete(f"profile:{user.id}")151 152 def _log_user_creation(self, user: User):153 # Manual audit trail154 from app.models import AuditLog155 156 audit_entry = AuditLog(157 entity_type="user",158 entity_id=user.id,159 action="create",160 actor_id=user.id, # Self-registration161 timestamp=datetime.utcnow(),162 ip_address=self.request_context.get("ip_address"), # Need to pass this in somehow163 user_agent=self.request_context.get("user_agent"), # This too164 metadata={165 "email": user.email,166 "username": user.username,167 "registration_source": "api"168 }169 )170 self.db.add(audit_entry)171 self.db.commit()172 173 def _generate_auth_token(self, user: User) -> str:174 # Manual JWT token generation for auth175 from jose import jwt176 from datetime import datetime, timedelta177 178 token_data = {179 "sub": str(user.id),180 "email": user.email,181 "exp": datetime.utcnow() + timedelta(days=7),182 "iat": datetime.utcnow(),183 "type": "access"184 }185 186 access_token = jwt.encode(187 token_data,188 os.getenv("SECRET_KEY"),189 algorithm="HS256"190 )191 192 # Also need refresh token193 refresh_data = {194 "sub": str(user.id),195 "exp": datetime.utcnow() + timedelta(days=30),196 "type": "refresh"197 }198 199 refresh_token = jwt.encode(200 refresh_data,201 os.getenv("SECRET_KEY"),202 algorithm="HS256"203 )204 205 # Store refresh token somewhere (Redis, database, etc.)206 cache_client.set(207 f"refresh_token:{user.id}",208 refresh_token,209 ex=30 * 24 * 60 * 60 # 30 days210 )211 212 return access_token213 214 # Meanwhile in Django:215 from django.core.mail import send_mail216 from django.contrib.auth.models import User217 218 # Create user with automatic validation, hashing, and duplicate checking219 user = User.objects.create_user(username=username, email=email, password=password)220 221 # Send verification email (email backend configured in settings.py)222 send_mail(223 'Verify your account',224 'Click here to verify...',225 'from@example.com',226 [user.email],227 fail_silently=False,228 )229 # Email backends: SMTP, Console, File, In-Memory, AWS SES, SendGrid, etc.230 # All swappable in settings.py without changing code
The Deployment Reality
FastAPI in production looks like this:
python
1 # Your deployment checklist:2 deployment_checklist = {3 "app_server": "Uvicorn (hope it doesn't crash)",4 "process_manager": "Gunicorn with Uvicorn workers (what?)",5 "static_files": "Nginx (configured manually)",6 "database_migrations": "Alembic (if you set it up right)",7 "admin_tasks": "SSH and run Python scripts like it's 2005",8 "monitoring": "Instrument everything manually",9 "health_checks": "Write your own",10 "graceful_shutdown": "Cross your fingers"11 }12 13 # Django deployment:14 # python manage.py migrate15 # python manage.py collectstatic16 # gunicorn project.wsgi17 # Done. Battle-tested for 20 years.
💡
I once watched a team spend two weeks figuring out why their FastAPI app would randomly stop accepting connections. Turns out Uvicorn doesn't handle worker recycling well under certain conditions.
Their solution? A cron job that restarts the entire application every 6 hours.
Professional software engineering, folks.
The Documentation Trap
"But the automatic API documentation!" - the "killer feature" that everyone thinks Django doesn't have.
First off, Django has had great API documentation tools for years. Django REST Framework comes with built-in schema generation, and tools like
drf-spectacular give you everything FastAPI does and more. The difference? Django's approach actually scales with real projects.Here's what happens when you try to use FastAPI's auto-docs in the real world:
python
1 # You start with one API2 @app.get("/api/users/{user_id}")3 async def get_user(user_id: int):4 return {"user_id": user_id}5 6 # Then you need internal vs external APIs7 @app.get("/api/users/{user_id}") # Public API8 @app.get("/internal/users/{user_id}") # Internal API with different fields9 @app.get("/admin/users/{user_id}") # Admin API with sensitive data10 11 # Now your auto-generated docs are:12 # 1. Showing internal endpoints to external users13 # 2. Missing crucial business context14 # 3. Exposing your entire API surface15 # 4. Completely useless for API versioning
⚠️
In Django, I use
drf-spectacular with ViewSets and can:- •Generate separate schemas for internal/external APIs
- •Version my APIs properly
- •Add detailed descriptions that actually explain the business logic
- •Customize what gets exposed based on user permissions
- •Generate client SDKs that actually work
FastAPI's "automatic" docs become manual the second you have real requirements.
But hey, at least your todo list tutorial has a pretty Swagger UI that nobody will ever look at.
The Hot Take
FastAPI shines in simple use cases and API documentation, but requires significant additional work when building full-featured production applications.
It's not even a framework - it's a routing library with excellent marketing that convinced developers that:
- •Type hints equal architecture (They don't)
- •Async everything is faster (It's often slower)
- •Dependency injection is elegant (Not with 15 dependencies per endpoint)
- •Batteries-included is bad (Until you need those batteries)
My FastAPI Journey
Every FastAPI project I've worked on follows the same pattern:
- •Day 1: "Wow, automatic docs! This is the future!"
- •Week 1: "Wait, there's no admin interface?"
- •Month 1: "How do I properly handle permissions?"
- •Month 3: "Why am I writing my own user management?"
- •Month 6: "Should we just use Django?"
Meanwhile, Django developers are already shipping features while FastAPI developers are still wiring up authentication.
The Final Verdict
FastAPI is excellent for specific use cases:
- •Microservices that do one thing well
- •Data science APIs where you're mostly serving ML models
- •High-performance WebSocket applications
- •Simple CRUD APIs that will stay simple
- •Teams that genuinely need async everywhere
But choose Django (with DRF) when you need:
- •User management and authentication out of the box
- •Admin interfaces for non-technical users
- •Complex permissions and authorization
- •Battle-tested patterns and conventions
- •A full-featured web application
- •Rapid development with common features
The real lesson? Use the right tool for the job. FastAPI isn't bad - it's just optimized for different things than Django. The problems arise when teams choose FastAPI for the hype, then spend months building features that Django gives you for free.
My advice: Start with Django unless you have a specific reason not to. You can always add FastAPI microservices later for the parts that actually need extreme performance. Your future self (and your team) will thank you when you're shipping features instead of reimplementing authentication for the fifth time.