Skip to main content
2025-07-2818 min read
Software Engineering

FastAPI: Why Minimalist Frameworks Maximize Your Work

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:
2from fastapi import FastAPI
3
4app = FastAPI()
5
6@app.get("/users/{user_id}")
7async 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
1from fastapi import FastAPI, Depends, HTTPException, Security, status
2from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
3from sqlalchemy.orm import Session
4from typing import Optional, List, Dict, Any
5from datetime import datetime, timedelta
6from jose import JWTError, jwt
7from passlib.context import CryptContext
8from pydantic import BaseModel, validator, Field
9from app.core.config import settings
10from app.db.session import get_db
11from app.models.user import User
12from app.schemas.user import UserCreate, UserUpdate, UserInDB
13from app.core.security import get_password_hash, verify_password
14from app.core.auth import create_access_token
15from app.api.deps import get_current_user, get_current_active_superuser
16from app.crud.base import CRUDBase
17from app.utils.email import send_email
18import logging
19from app.core.logging import logger
20from app.core.cache import cache
21from app.core.ratelimit import limiter
22from app.middleware.correlation import correlation_id
23from app.core.exceptions import UserNotFound, InvalidCredentials
24from app.core.permissions import has_permission
25
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:
2async 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 logic
9 # Check JWT, validate, query database, handle cache
10 # All manually implemented
11 pass
12
13# Now every endpoint needs:
14@app.get("/protected")
15async 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 function
24 pass
25
26# Meanwhile in Django:
27@api_view(['GET'])
28@permission_classes([IsAuthenticated])
29def protected_route(request):
30 # Just works. User is at request.user
31 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:
  1. Database queries - That N+1 query problem adds 150ms
  2. External API calls - Stripe still takes 200ms to respond
  3. Business logic - That algorithm is still O(n³)
  4. 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.
40%30%20%10%0%Where Your 500ms Response Time Actually GoesExternal APIDatabase QuerySerializationBusiness LogicFastAPI Framework
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)
2async def my_view(request):
3 # Make concurrent external API calls
4 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")
3async def fetch_data():
4 # "It's async so it must be fast!"
5 result = await some_database_call() # Still blocks on I/O
6
7 # But wait, your ORM isn't actually async
8 # So you're using sync SQLAlchemy in an async function
9 # Congrats, you've made it slower
10
11# The reality I keep encountering:
12async 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 overhead
18 db_result = await run_in_threadpool(sync_database_call)
19
20 # And your cache library is sync - more overhead
21 cache_result = await run_in_threadpool(redis_client.get, "key")
22
23 # You've added threadpool overhead to everything
24 # 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:
2fastapi_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):
10django_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:
  1. Core: Raw SQL construction kit for masochists
  2. 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 connection
2from sqlalchemy import create_engine
3from sqlalchemy.ext.declarative import declarative_base
4from sqlalchemy.orm import sessionmaker
5
6SQLALCHEMY_DATABASE_URL = "postgresql://user:password@localhost/dbname"
7engine = create_engine(SQLALCHEMY_DATABASE_URL)
8SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
9Base = declarative_base()
10
11# Then, create a dependency to get DB sessions
12def get_db():
13 db = SessionLocal()
14 try:
15 yield db
16 finally:
17 db.close()
18
19# Define your model (Layer 1)
20class 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)
27class UserBase(BaseModel):
28 email: str
29
30class UserCreate(UserBase):
31 password: str
32
33class User(UserBase):
34 id: int
35 is_active: bool
36 class Config:
37 orm_mode = True
38
39# Now create a CRUD class (Layer 3)
40class 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 hashing
46 # Manual object creation
47 # Manual everything
48 pass
49
50# Finally, use it in your endpoint (Layer 4)
51@app.post("/users/", response_model=User)
52def 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:
  1. SQLAlchemy models
  2. Pydantic schemas
  3. CRUD classes
  4. Dependency injection
  5. Manual session management
  6. Manual transaction handling

HTTP Request

FastAPI Route

Dependency Injection

Pydantic Validation

CRUD Layer

SQLAlchemy Session

SQLAlchemy Model

Database

Django Request

Django View

Django ORM

Database

Now here's the same thing in Django:
python
1# Django model (that's it, you're done)
2class User(models.Model):
3 email = models.EmailField(unique=True)
4 is_active = models.BooleanField(default=True)
5
6# Django view
7@api_view(['POST'])
8def create_user(request):
9 serializer = UserSerializer(data=request.data)
10 if serializer.is_valid():
11 serializer.save() # Handles everything
12 return Response(serializer.data)
13 return Response(serializer.errors)
14
15# Or with viewsets (even simpler)
16class UserViewSet(ModelViewSet):
17 queryset = User.objects.all()
18 serializer_class = UserSerializer
19 # 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 query
2users = 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 SQL
9users = 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 SQLAlchemy
2class 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 fields
8
9# Then in Pydantic schemas:
10class UserBase(BaseModel):
11 email: EmailStr
12 username: str
13
14class UserCreate(UserBase):
15 password: str
16
17class UserUpdate(UserBase):
18 password: Optional[str] = None
19
20class UserInDB(UserBase):
21 id: int
22 created_at: datetime
23
24class User(UserInDB):
25 pass
26
27# That's 5-6 classes for ONE model
28# 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:
2class UserSerializer(serializers.ModelSerializer):
3 # Layer 1: Serialization handles more than just JSON conversion
4 full_name = serializers.SerializerMethodField()
5 subscription_status = serializers.SerializerMethodField()
6
7 class Meta:
8 model = User
9 fields = ['id', 'email', 'full_name', 'subscription_status']
10
11 def get_subscription_status(self, obj):
12 # Complex business logic with caching
13 return cache.get_or_set(
14 f'sub_status_{obj.id}',
15 lambda: calculate_subscription_status(obj),
16 timeout=300
17 )
18
19 def validate_email(self, value):
20 # Validation that needs database access
21 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 clear
27 with transaction.atomic():
28 user = super().create(validated_data)
29 create_related_records(user)
30 send_notifications(user)
31 return user
32
33class UserViewSet(viewsets.ModelViewSet):
34 # Layer 2: ViewSets handle request logic
35 serializer_class = UserSerializer
36 queryset = User.objects.all()
37
38 def get_queryset(self):
39 # Complex query optimization happens here
40 qs = super().get_queryset()
41 if self.action == 'list':
42 # Prevent N+1 queries
43 qs = qs.select_related('profile').prefetch_related('subscriptions')
44 return qs
45
46 def get_serializer_class(self):
47 # Different serializers for different actions
48 if self.action == 'list':
49 return UserListSerializer # Minimal fields
50 elif self.request.user.is_staff:
51 return UserAdminSerializer # All fields
52 return UserSerializer # Standard fields
53
54# FastAPI: Everything jammed into one place
55@app.post("/users")
56async 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-multipart
3# 2. Write extensive JWT boilerplate
4# 3. Handle password hashing manually
5# 4. Implement token refresh manually
6# 5. Debug JWT decode errors at 3 AM
7
8from jose import JWTError, jwt
9from passlib.context import CryptContext
10from datetime import datetime, timedelta
11
12pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
13
14def verify_password(plain_password, hashed_password):
15 return pwd_context.verify(plain_password, hashed_password)
16
17def get_password_hash(password):
18 return pwd_context.hash(password)
19
20def 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 implementation
27
28# Django REST Framework:
29# pip install djangorestframework-simplejwt
30REST_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:
2def has_permission(user: User, resource: str, action: str):
3 # Implement entire RBAC system from scratch
4 # Handle object-level permissions manually
5 # Build permission checking decorators
6 # Create permission classes
7 # ... significant code later
8
9@app.get("/api/documents/{doc_id}")
10async 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 check
16 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 doc
20
21# Django REST Framework:
22class IsOwnerOrReadOnly(permissions.BasePermission):
23 def has_object_permission(self, request, view, obj):
24 # Read permissions for any request
25 if request.method in permissions.SAFE_METHODS:
26 return True
27 # Write permissions only for owner
28 return obj.owner == request.user
29
30class 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")
3async 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 ordering
29 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:
38class UserViewSet(viewsets.ModelViewSet):
39 queryset = User.objects.all()
40 serializer_class = UserSerializer
41 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)
3from slowapi import Limiter, _rate_limit_exceeded_handler
4from slowapi.util import get_remote_address
5
6limiter = Limiter(key_func=get_remote_address)
7app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
8
9@app.get("/api/endpoint")
10@limiter.limit("5/minute") # Hope this works with async
11async 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:
17REST_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:
29class MyViewSet(viewsets.ModelViewSet):
30 throttle_classes = [UserRateThrottle]
31 # Automatic rate limiting with multiple backends

Pagination: Build Your Own vs DRF

python
1# FastAPI pagination:
2def paginate(query, skip: int = 0, limit: int = 100):
3 # Build your own pagination logic
4 # Handle edge cases
5 # Create response format
6 # Add metadata
7 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 calculate
17 }
18
19# Django REST Framework:
20class StandardResultsSetPagination(PageNumberPagination):
21 page_size = 100
22 page_size_query_param = 'page_size'
23 max_page_size = 1000
24
25class MyViewSet(viewsets.ModelViewSet):
26 pagination_class = StandardResultsSetPagination
27 # Automatic pagination with links, counts, and metadata

API Versioning: ??? vs DRF

python
1# FastAPI versioning:
2# Option 1: URL path versioning (manual)
3app_v1 = FastAPI()
4app_v2 = FastAPI()
5
6@app_v1.get("/users")
7def get_users_v1():
8 pass
9
10@app_v2.get("/users")
11def get_users_v2():
12 pass
13
14app.mount("/api/v1", app_v1)
15app.mount("/api/v2", app_v2)
16# Good luck maintaining this
17
18# Django REST Framework:
19REST_FRAMEWORK = {
20 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.NamespaceVersioning',
21 # Or URLPathVersioning, QueryParameterVersioning, HeaderVersioning, etc.
22}
23
24class MyViewSet(viewsets.ModelViewSet):
25 def list(self, request):
26 if request.version == 'v1':
27 # v1 logic
28 elif request.version == 'v2':
29 # v2 logic
💡
Every time you need standard API functionality in FastAPI, you either:
  1. Install a third-party library (that may or may not be maintained)
  2. Build it yourself from scratch
  3. 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:
2import pytest
3from fastapi.testclient import TestClient
4from sqlalchemy import create_engine
5from sqlalchemy.orm import sessionmaker
6from app.main import app
7from app.database import Base, get_db
8from app.models import User
9
10# Set up test database
11SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db"
12engine = create_engine(SQLALCHEMY_DATABASE_URL)
13TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
14
15def override_get_db():
16 try:
17 db = TestingSessionLocal()
18 yield db
19 finally:
20 db.close()
21
22app.dependency_overrides[get_db] = override_get_db
23client = TestClient(app)
24
25@pytest.fixture
26def test_db():
27 Base.metadata.create_all(bind=engine)
28 yield
29 Base.metadata.drop_all(bind=engine)
30
31def 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 == 200
37
38# Django equivalent:
39from django.test import TestCase
40from django.contrib.auth.models import User
41
42class 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:
2from typing import Optional, List
3from datetime import datetime
4from sqlalchemy.orm import Session
5from passlib.context import CryptContext
6from app.core.cache import cache_client
7import logging
8
9pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
10logger = logging.getLogger(__name__)
11
12class UserService:
13 def __init__(self, db: Session):
14 self.db = db
15
16 def create_user(self, user_data: UserCreate) -> User:
17 # Manual transaction handling
18 try:
19 # Manual password hashing
20 hashed_password = pwd_context.hash(user_data.password)
21
22 # Manual validation beyond Pydantic
23 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 manually
30 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 save
41 self.db.add(db_user)
42 self.db.commit()
43 self.db.refresh(db_user)
44
45 # Manual cache invalidation
46 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 too
54
55 # Manual audit logging
56 logger.info(f"User created: {db_user.email}")
57
58 return db_user
59
60 except Exception as e:
61 # Manual rollback
62 self.db.rollback()
63 logger.error(f"User creation failed: {str(e)}")
64 raise
65
66 def _send_welcome_email(self, user: User):
67 # Manual email implementation with SendGrid
68 import os
69 from sendgrid import SendGridAPIClient
70 from sendgrid.helpers.mail import Mail
71
72 try:
73 if os.getenv("ENVIRONMENT") == "local":
74 # Local testing - just log to console
75 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 return
79
80 # Production - use SendGrid
81 sg = SendGridAPIClient(os.getenv('SENDGRID_API_KEY'))
82
83 # Build email manually
84 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 manually
96 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 here
113 # But that means setting up Celery or similar...
114 # For now, just track the failure
115 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 retry
119 from app.models import FailedEmail
120
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 manually
133 from app.models import UserProfile
134
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 cache
150 cache_client.delete(f"profile:{user.id}")
151
152 def _log_user_creation(self, user: User):
153 # Manual audit trail
154 from app.models import AuditLog
155
156 audit_entry = AuditLog(
157 entity_type="user",
158 entity_id=user.id,
159 action="create",
160 actor_id=user.id, # Self-registration
161 timestamp=datetime.utcnow(),
162 ip_address=self.request_context.get("ip_address"), # Need to pass this in somehow
163 user_agent=self.request_context.get("user_agent"), # This too
164 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 auth
175 from jose import jwt
176 from datetime import datetime, timedelta
177
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 token
193 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 days
210 )
211
212 return access_token
213
214# Meanwhile in Django:
215from django.core.mail import send_mail
216from django.contrib.auth.models import User
217
218# Create user with automatic validation, hashing, and duplicate checking
219user = User.objects.create_user(username=username, email=email, password=password)
220
221# Send verification email (email backend configured in settings.py)
222send_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:
2deployment_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 migrate
15# python manage.py collectstatic
16# gunicorn project.wsgi
17# 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 API
2@app.get("/api/users/{user_id}")
3async def get_user(user_id: int):
4 return {"user_id": user_id}
5
6# Then you need internal vs external APIs
7@app.get("/api/users/{user_id}") # Public API
8@app.get("/internal/users/{user_id}") # Internal API with different fields
9@app.get("/admin/users/{user_id}") # Admin API with sensitive data
10
11# Now your auto-generated docs are:
12# 1. Showing internal endpoints to external users
13# 2. Missing crucial business context
14# 3. Exposing your entire API surface
15# 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:
  1. Day 1: "Wow, automatic docs! This is the future!"
  2. Week 1: "Wait, there's no admin interface?"
  3. Month 1: "How do I properly handle permissions?"
  4. Month 3: "Why am I writing my own user management?"
  5. 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.