Introduction: Python's Dominance in Web Development
Python has solidified its position as one of the most popular languages for web development, and for good reason. Its clean syntax, vast ecosystem, and two powerhouse frameworks — Django and FastAPI — make it possible to build everything from content management systems to high-performance APIs. Django, the "batteries included" framework, handles complex web applications with built-in authentication, admin panels, and ORM. FastAPI, the modern async framework, delivers blazing performance and automatic API documentation.
This guide walks you through building complete projects with both frameworks, compares them head-to-head, and shows you how to deploy to production.
Django: The Batteries-Included Framework
Project Setup from Scratch
## Create and activate a virtual environment
python -m venv venv
## Windows:
venvScriptsactivate
## Linux/Mac:
source venv/bin/activate
## Install Django and essential packages
pip install django djangorestframework django-cors-headers
django-filter celery redis pillow gunicorn psycopg2-binary
## Create a new project
django-admin startproject myproject .
## Create your first app
python manage.py startapp users
python manage.py startapp products
python manage.py startapp orders
## Project structure after setup:
## myproject/
## ├── manage.py
## ├── myproject/
## │ ├── __init__.py
## │ ├── settings.py
## │ ├── urls.py
## │ ├── asgi.py
## │ └── wsgi.py
## ├── users/
## ├── products/
## └── orders/
Settings Configuration
# myproject/settings.py
import os
from pathlib import Path
from datetime import timedelta
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "change-me-in-production")
DEBUG = os.environ.get("DEBUG", "True") == "True"
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
# Third-party
"rest_framework",
"corsheaders",
"django_filters",
# Local apps
"users.apps.UsersConfig",
"products.apps.ProductsConfig",
"orders.apps.OrdersConfig",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
# Database configuration
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("DB_NAME", "myproject"),
"USER": os.environ.get("DB_USER", "postgres"),
"PASSWORD": os.environ.get("DB_PASSWORD", "password"),
"HOST": os.environ.get("DB_HOST", "localhost"),
"PORT": os.environ.get("DB_PORT", "5432"),
}
}
# REST Framework configuration
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
"rest_framework.authentication.SessionAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 20,
"DEFAULT_FILTER_BACKENDS": [
"django_filters.rest_framework.DjangoFilterBackend",
"rest_framework.filters.SearchFilter",
"rest_framework.filters.OrderingFilter",
],
}
# Celery configuration
CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "redis://localhost:6379/0")
CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", "redis://localhost:6379/0")
# Static and media files
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
Models: The Database Layer
# products/models.py
from django.db import models
from django.contrib.auth.models import User
from django.core.validators import MinValueValidator
from django.utils.text import slugify
import uuid
class Category(models.Model):
name = models.CharField(max_length=100, unique=True)
slug = models.SlugField(max_length=100, unique=True, blank=True)
description = models.TextField(blank=True)
parent = models.ForeignKey(
"self", on_delete=models.CASCADE,
null=True, blank=True, related_name="children"
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name_plural = "categories"
ordering = ["name"]
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
def __str__(self):
return self.name
class Product(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=200)
slug = models.SlugField(max_length=200, unique=True, blank=True)
description = models.TextField()
price = models.DecimalField(
max_digits=10, decimal_places=2,
validators=[MinValueValidator(0.01)]
)
stock = models.PositiveIntegerField(default=0)
category = models.ForeignKey(
Category, on_delete=models.PROTECT, related_name="products"
)
image = models.ImageField(upload_to="products/%Y/%m/", blank=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
created_by = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, related_name="products"
)
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["slug"]),
models.Index(fields=["category", "is_active"]),
models.Index(fields=["-created_at"]),
]
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
@property
def is_in_stock(self):
return self.stock > 0
def __str__(self):
return self.name
## Create and apply migrations
python manage.py makemigrations
python manage.py migrate
## Create a superuser for the admin panel
python manage.py createsuperuser
Admin Panel Customization
# products/admin.py
from django.contrib import admin
from .models import Product, Category
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ["name", "slug", "parent", "created_at"]
prepopulated_fields = {"slug": ("name",)}
search_fields = ["name"]
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ["name", "price", "stock", "category", "is_active", "created_at"]
list_filter = ["is_active", "category", "created_at"]
search_fields = ["name", "description"]
prepopulated_fields = {"slug": ("name",)}
list_editable = ["price", "stock", "is_active"]
readonly_fields = ["created_at", "updated_at"]
list_per_page = 25
fieldsets = (
(None, {"fields": ("name", "slug", "description", "category")}),
("Pricing & Stock", {"fields": ("price", "stock")}),
("Media", {"fields": ("image",)}),
("Status", {"fields": ("is_active",)}),
("Metadata", {"fields": ("created_at", "updated_at", "created_by"), "classes": ("collapse",)}),
)
def save_model(self, request, obj, form, change):
if not change: # Only set created_by on creation
obj.created_by = request.user
super().save_model(request, obj, form, change)
Django REST Framework: Serializers, ViewSets, Routers
# products/serializers.py
from rest_framework import serializers
from .models import Product, Category
class CategorySerializer(serializers.ModelSerializer):
product_count = serializers.IntegerField(read_only=True)
class Meta:
model = Category
fields = ["id", "name", "slug", "description", "parent", "product_count"]
class ProductSerializer(serializers.ModelSerializer):
category_name = serializers.CharField(source="category.name", read_only=True)
is_in_stock = serializers.BooleanField(read_only=True)
class Meta:
model = Product
fields = [
"id", "name", "slug", "description", "price",
"stock", "category", "category_name", "image",
"is_active", "is_in_stock", "created_at", "updated_at"
]
read_only_fields = ["id", "slug", "created_at", "updated_at"]
def validate_price(self, value):
if value <= 0:
raise serializers.ValidationError("Price must be greater than zero.")
return value
# products/views.py
from rest_framework import viewsets, permissions, status, filters
from rest_framework.decorators import action
from rest_framework.response import Response
from django_filters.rest_framework import DjangoFilterBackend
from django.db.models import Count
from .models import Product, Category
from .serializers import ProductSerializer, CategorySerializer
class ProductViewSet(viewsets.ModelViewSet):
queryset = Product.objects.select_related("category", "created_by").filter(is_active=True)
serializer_class = ProductSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ["category", "is_active"]
search_fields = ["name", "description"]
ordering_fields = ["price", "created_at", "name"]
ordering = ["-created_at"]
lookup_field = "slug"
def perform_create(self, serializer):
serializer.save(created_by=self.request.user)
@action(detail=False, methods=["get"])
def featured(self, request):
featured = self.get_queryset().filter(stock__gt=0).order_by("-created_at")[:10]
serializer = self.get_serializer(featured, many=True)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def adjust_stock(self, request, slug=None):
product = self.get_object()
quantity = request.data.get("quantity", 0)
product.stock += int(quantity)
product.save()
return Response({"stock": product.stock})
class CategoryViewSet(viewsets.ModelViewSet):
queryset = Category.objects.annotate(product_count=Count("products"))
serializer_class = CategorySerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
lookup_field = "slug"
# products/urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import ProductViewSet, CategoryViewSet
router = DefaultRouter()
router.register(r"products", ProductViewSet)
router.register(r"categories", CategoryViewSet)
urlpatterns = [
path("", include(router.urls)),
]
# myproject/urls.py
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include("products.urls")),
path("api/auth/", include("rest_framework.urls")),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
Django Signals
# products/signals.py
from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver
from django.core.cache import cache
from .models import Product
@receiver(post_save, sender=Product)
def product_saved(sender, instance, created, **kwargs):
# Invalidate cache when product is updated
cache.delete(f"product_{instance.slug}")
cache.delete("featured_products")
if created:
# Send notification for new products
from .tasks import send_new_product_notification
send_new_product_notification.delay(str(instance.id))
@receiver(pre_delete, sender=Product)
def product_deleting(sender, instance, **kwargs):
# Clean up associated files
if instance.image:
instance.image.delete(save=False)
Celery Background Tasks
# myproject/celery.py
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
app = Celery("myproject")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
# products/tasks.py
from celery import shared_task
from django.core.mail import send_mail
@shared_task(bind=True, max_retries=3)
def send_new_product_notification(self, product_id):
try:
from .models import Product
product = Product.objects.get(id=product_id)
send_mail(
subject=f"New Product: {product.name}",
message=f"Check out our new product: {product.name} - ${product.price}",
from_email="noreply@example.com",
recipient_list=["admin@example.com"],
)
except Exception as exc:
self.retry(exc=exc, countdown=60)
@shared_task
def generate_daily_report():
from .models import Product
from django.utils import timezone
from datetime import timedelta
yesterday = timezone.now() - timedelta(days=1)
new_products = Product.objects.filter(created_at__gte=yesterday).count()
low_stock = Product.objects.filter(stock__lt=10, is_active=True).count()
return {
"new_products": new_products,
"low_stock_alerts": low_stock,
"generated_at": timezone.now().isoformat()
}
## Start the Celery worker
celery -A myproject worker --loglevel=info
## Start Celery Beat for scheduled tasks
celery -A myproject beat --loglevel=info
## Run Django development server
python manage.py runserver
FastAPI: The Modern Async Framework
Project Setup
## Create project structure
mkdir fastapi-project && cd fastapi-project
python -m venv venv
source venv/bin/activate # or venvScriptsactivate on Windows
## Install dependencies
pip install fastapi uvicorn[standard] sqlalchemy alembic
pydantic pydantic-settings python-jose[cryptography]
passlib[bcrypt] python-multipart aiofiles httpx
## Project structure:
## fastapi-project/
## ├── app/
## │ ├── __init__.py
## │ ├── main.py
## │ ├── config.py
## │ ├── database.py
## │ ├── models/
## │ ├── schemas/
## │ ├── routers/
## │ ├── services/
## │ ├── middleware/
## │ └── dependencies.py
## ├── alembic/
## ├── alembic.ini
## ├── requirements.txt
## └── tests/
Core Application with Dependency Injection
# app/config.py
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
app_name: str = "FastAPI Project"
debug: bool = False
database_url: str = "postgresql+asyncpg://user:pass@localhost/mydb"
redis_url: str = "redis://localhost:6379/0"
secret_key: str = "your-secret-key-here"
algorithm: str = "HS256"
access_token_expire_minutes: int = 30
class Config:
env_file = ".env"
@lru_cache
def get_settings():
return Settings()
# app/database.py
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from .config import get_settings
settings = get_settings()
engine = create_async_engine(settings.database_url, echo=settings.debug)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db():
async with async_session() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
# app/models/product.py
import uuid
from sqlalchemy import Column, String, Float, Integer, Boolean, DateTime, ForeignKey, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from ..database import Base
class Product(Base):
__tablename__ = "products"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(200), nullable=False, index=True)
slug = Column(String(200), unique=True, nullable=False, index=True)
description = Column(Text, nullable=False)
price = Column(Float, nullable=False)
stock = Column(Integer, default=0)
is_active = Column(Boolean, default=True)
category_id = Column(UUID(as_uuid=True), ForeignKey("categories.id"))
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
category = relationship("Category", back_populates="products")
# app/schemas/product.py
from pydantic import BaseModel, Field, field_validator
from uuid import UUID
from datetime import datetime
from typing import Optional
class ProductBase(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
description: str = Field(..., min_length=10)
price: float = Field(..., gt=0)
stock: int = Field(default=0, ge=0)
category_id: UUID
is_active: bool = True
@field_validator("name")
@classmethod
def name_must_not_be_empty(cls, v):
if not v.strip():
raise ValueError("Name cannot be empty or whitespace")
return v.strip()
class ProductCreate(ProductBase):
pass
class ProductUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = None
price: Optional[float] = Field(None, gt=0)
stock: Optional[int] = Field(None, ge=0)
is_active: Optional[bool] = None
class ProductResponse(ProductBase):
id: UUID
slug: str
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
# app/routers/products.py
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from typing import Optional
from uuid import UUID
from ..database import get_db
from ..models.product import Product
from ..schemas.product import ProductCreate, ProductUpdate, ProductResponse
from ..dependencies import get_current_user
from slugify import slugify
router = APIRouter(prefix="/products", tags=["products"])
@router.get("/", response_model=list[ProductResponse])
async def list_products(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
search: Optional[str] = None,
category_id: Optional[UUID] = None,
min_price: Optional[float] = None,
max_price: Optional[float] = None,
db: AsyncSession = Depends(get_db)
):
query = select(Product).where(Product.is_active == True)
if search:
query = query.where(Product.name.ilike(f"%{search}%"))
if category_id:
query = query.where(Product.category_id == category_id)
if min_price is not None:
query = query.where(Product.price >= min_price)
if max_price is not None:
query = query.where(Product.price <= max_price)
query = query.offset(skip).limit(limit).order_by(Product.created_at.desc())
result = await db.execute(query)
return result.scalars().all()
@router.post("/", response_model=ProductResponse, status_code=status.HTTP_201_CREATED)
async def create_product(
product_data: ProductCreate,
db: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user)
):
product = Product(
**product_data.model_dump(),
slug=slugify(product_data.name)
)
db.add(product)
await db.flush()
await db.refresh(product)
return product
@router.get("/{slug}", response_model=ProductResponse)
async def get_product(slug: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Product).where(Product.slug == slug))
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
return product
@router.patch("/{slug}", response_model=ProductResponse)
async def update_product(
slug: str,
product_data: ProductUpdate,
db: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user)
):
result = await db.execute(select(Product).where(Product.slug == slug))
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
update_data = product_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(product, field, value)
await db.flush()
await db.refresh(product)
return product
# app/main.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from .config import get_settings
from .database import engine, Base
from .routers import products, auth
settings = get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: create tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
# Shutdown: dispose engine
await engine.dispose()
app = FastAPI(
title=settings.app_name,
version="1.0.0",
lifespan=lifespan
)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(products.router, prefix="/api")
app.include_router(auth.router, prefix="/api")
@app.get("/healthz")
async def health_check():
return {"status": "healthy"}
## Run the FastAPI development server
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
## Access auto-generated API docs
## Swagger UI: http://localhost:8000/docs
## ReDoc: http://localhost:8000/redoc
JWT Authentication in FastAPI
# app/dependencies.py
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from .config import get_settings
settings = get_settings()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
async def get_current_user(token: str = Depends(oauth2_scheme)):
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])
user_id: str = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
return user_id
WebSocket Support
# app/routers/websocket.py
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from typing import List
router = APIRouter()
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def broadcast(self, message: str):
for connection in self.active_connections:
await connection.send_text(message)
manager = ConnectionManager()
@router.websocket("/ws/notifications")
async def websocket_endpoint(websocket: WebSocket):
await manager.connect(websocket)
try:
while True:
data = await websocket.receive_text()
await manager.broadcast(f"Notification: {data}")
except WebSocketDisconnect:
manager.disconnect(websocket)
Alembic Migrations
## Initialize Alembic
alembic init alembic
## Generate a migration
alembic revision --autogenerate -m "create products table"
## Apply migrations
alembic upgrade head
## Rollback one step
alembic downgrade -1
## View migration history
alembic history
Django vs FastAPI: Detailed Comparison
| Feature | Django | FastAPI |
|---|---|---|
| Architecture | Monolithic, batteries-included | Micro-framework, pick your tools |
| Performance | Synchronous (async support added) | Fully async, near Go/Node speeds |
| ORM | Built-in Django ORM | SQLAlchemy (manual setup) |
| Admin Panel | Built-in, production-ready | None (use SQLAdmin or custom) |
| Authentication | Built-in (sessions, tokens) | Manual (JWT, OAuth2 helpers) |
| API Documentation | Manual or drf-spectacular | Automatic Swagger + ReDoc |
| Type Safety | Optional type hints | Pydantic enforced validation |
| Learning Curve | Moderate (many conventions) | Lower (Python-native patterns) |
| WebSocket Support | Via Django Channels | Native support |
| Template Engine | Built-in Django Templates | Jinja2 (manual setup) |
| Background Tasks | Celery (separate process) | Built-in + Celery option |
| Community/Ecosystem | Massive, 20+ years | Growing fast, modern |
| Best For | Full web apps, CMS, e-commerce | APIs, microservices, real-time apps |
Deployment: Production Setup
Django with Gunicorn + Nginx
## Install Gunicorn
pip install gunicorn
## Run with Gunicorn (production)
gunicorn myproject.wsgi:application
--bind 0.0.0.0:8000
--workers 4
--threads 2
--timeout 120
--access-logfile -
--error-logfile -
## Collect static files
python manage.py collectstatic --noinput
# /etc/nginx/sites-available/myproject
server {
listen 80;
server_name example.com;
location /static/ {
alias /var/www/myproject/staticfiles/;
expires 30d;
}
location /media/ {
alias /var/www/myproject/media/;
expires 7d;
}
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
FastAPI with Uvicorn + Nginx
## Run with Uvicorn (production)
uvicorn app.main:app
--host 0.0.0.0
--port 8000
--workers 4
--loop uvloop
--http httptools
--access-log
Troubleshooting Common Issues
Problem: Django Migrations Conflict
Cause: Multiple developers created migrations simultaneously from different branches.
Solution:
## Merge conflicting migrations
python manage.py makemigrations --merge
## If that fails, reset migrations (DEVELOPMENT ONLY)
python manage.py migrate app_name zero
python manage.py makemigrations app_name
python manage.py migrate app_name
Problem: FastAPI Circular Import Error
Cause: Models import schemas that import models.
Solution: Use TYPE_CHECKING for type hints and lazy imports for runtime.
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .models import Product
Problem: N+1 Query Problem in Django
Cause: Accessing related objects in a loop without prefetching.
Solution:
# BAD: N+1 queries
products = Product.objects.all()
for p in products:
print(p.category.name) # Each access hits the database
# GOOD: 2 queries total
products = Product.objects.select_related("category").all()
for p in products:
print(p.category.name) # Already loaded
# For reverse/many-to-many relationships use prefetch_related
categories = Category.objects.prefetch_related("products").all()
Quick Reference Cheat Sheet
| Task | Django Command | FastAPI Equivalent |
|---|---|---|
| Start Project | django-admin startproject name | Create main.py manually |
| Run Server | python manage.py runserver | uvicorn app.main:app --reload |
| Create Migration | python manage.py makemigrations | alembic revision --autogenerate |
| Apply Migration | python manage.py migrate | alembic upgrade head |
| Shell | python manage.py shell | python + manual imports |
| Create Admin | python manage.py createsuperuser | Manual setup required |
| Run Tests | python manage.py test | pytest |
| API Docs | Install drf-spectacular | Automatic at /docs |