Overview: Three Python Powerhouses
JAX, Django, and FastAPI represent three fundamentally different domains in the Python ecosystem—numerical computing, full-stack web development, and high-performance API building. Comparing them isn't about declaring a single winner; it's about understanding which tool solves your specific problem. This tutorial walks you through what each framework offers, why it matters, how to build real-world applications with it, and the best practices that seasoned developers follow. By the end, you'll have a clear mental model for choosing—and perhaps combining—these technologies in your own projects.
JAX: Numerical Computing with Automatic Differentiation
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →What JAX Is
JAX is a library for high-performance numerical computing and machine learning research, developed by Google Research. It provides composable transformations—automatic differentiation (grad), just-in-time compilation (jit), vectorization (vmap), and parallelization (pmap)—that work on familiar NumPy-style code. JAX is not a web framework; it lives in the world of tensors, gradients, and XLA-optimized kernels. It matters because it gives researchers and engineers the ability to write expressive Python code that runs at near-C++ speeds on CPUs, GPUs, and TPUs without manual optimization.
Why JAX Matters
Traditional numerical Python (NumPy, SciPy) is easy to write but slow on large-scale problems. Frameworks like PyTorch and TensorFlow offer speed but often lock you into specific graph abstractions. JAX bridges this gap: write idiomatic NumPy code, apply pure functional transformations, and let the XLA compiler handle the rest. This composability is revolutionary—you can differentiate through a JIT-compiled function, vectorize it across batch dimensions, and distribute it across a TPU pod, all with minimal code changes. For ML researchers prototyping new optimization algorithms, probabilistic models, or physics simulations, JAX is a game-changer.
Getting Started with JAX
Install JAX with CPU support (or GPU/TPU variants):
pip install jax jaxlib # CPU version
# For GPU (CUDA 12):
pip install jax[cuda12] jaxlib
Practical Example: Linear Regression with Gradients
Let's implement a simple linear regression model from scratch, compute gradients automatically, and JIT-compile the training step.
import jax
import jax.numpy as jnp
from jax import grad, jit, vmap
import matplotlib.pyplot as plt
# Generate synthetic data
key = jax.random.PRNGKey(42)
key, subkey = jax.random.split(key)
X = jax.random.normal(subkey, (100, 3))
key, subkey = jax.random.split(key)
true_w = jnp.array([2.5, -1.3, 0.8])
true_b = 0.5
noise = 0.1
Y = jnp.dot(X, true_w) + true_b + jax.random.normal(subkey, (100,)) * noise
# Define the model
def model(params, x):
w, b = params
return jnp.dot(x, w) + b
# Mean squared error loss
def loss_fn(params, x, y):
predictions = model(params, x)
return jnp.mean((predictions - y) ** 2)
# Use grad to get derivative w.r.t params
grad_loss = grad(loss_fn)
# JIT-compiled training step
@jit
def train_step(params, x, y, learning_rate):
grads = grad_loss(params, x, y)
w, b = params
w_updated = w - learning_rate * grads[0]
b_updated = b - learning_rate * grads[1]
return (w_updated, b_updated)
# Initialize parameters
init_w = jnp.zeros(3)
init_b = 0.0
params = (init_w, init_b)
# Training loop
for epoch in range(500):
params = train_step(params, X, Y, 0.01)
if epoch % 100 == 0:
current_loss = loss_fn(params, X, Y)
print(f"Epoch {epoch}: loss = {current_loss:.4f}")
print(f"Learned weights: {params[0]}")
print(f"Learned bias: {params[1]}")
print(f"True weights: {true_w}, True bias: {true_b}")
JAX Best Practices
- Embrace functional purity: JAX transformations work on pure functions. Avoid side effects and global state inside functions you plan to
jitorgrad. - Use
jax.random.PRNGKeyfor randomness: JAX's random number generation is explicitly stateful via keys. Never use global PRNG state—split keys and pass them explicitly. - Pre-allocate arrays when possible: JAX arrays are immutable. Use
jnp.zerosorjnp.onesto create buffers and update via index operations (at[...].set(...)) rather than in-place mutation. - Profile with
jax.profiler: When performance isn't what you expect, use the built-in profiler to identify XLA compilation bottlenecks or memory transfer issues. - Start with CPU, scale to GPU/TPU: JAX code is device-agnostic. Develop and debug on CPU, then switch to GPU/TPU by changing the device placement—no code changes required.
Django: The Full-Stack Web Framework
What Django Is
Django is a high-level Python web framework that follows the "batteries-included" philosophy. It provides an ORM, admin interface, authentication system, form handling, template engine, and much more—all out of the box. Django is built around the MTV (Model-Template-View) architectural pattern and emphasizes rapid development with clean, pragmatic design. It matters because it remains the gold standard for building complex, database-driven web applications quickly, from content management systems to e-commerce platforms and social networks.
Why Django Matters
In an ecosystem increasingly fragmented into microservices and specialized tools, Django offers a cohesive, integrated experience. When you need to ship a production-ready application with user authentication, database migrations, an admin dashboard, and secure session management—all within weeks, not months—Django delivers. Its ORM abstracts away SQL complexity while still allowing raw queries when needed. The admin interface alone saves hundreds of hours of CRUD development. Moreover, Django's security defaults (CSRF protection, XSS prevention, clickjacking defense) mean you're less likely to ship vulnerabilities.
Getting Started with Django
Install Django and create a project:
pip install django
django-admin startproject myproject
cd myproject
python manage.py startapp api
python manage.py migrate
python manage.py createsuperuser
Practical Example: A Task Management API
Let's build a REST API for managing tasks—complete with models, serializers, views, and URL routing using Django REST Framework.
models.py — Define the data model:
from django.db import models
from django.contrib.auth.models import User
class Task(models.Model):
PRIORITY_CHOICES = [
('low', 'Low'),
('medium', 'Medium'),
('high', 'High'),
]
title = models.CharField(max_length=200)
description = models.TextField(blank=True, null=True)
priority = models.CharField(max_length=10, choices=PRIORITY_CHOICES, default='medium')
is_completed = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
assigned_to = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='tasks')
class Meta:
ordering = ['-created_at']
def __str__(self):
return self.title
serializers.py — Convert models to JSON and validate input:
from rest_framework import serializers
from .models import Task
class TaskSerializer(serializers.ModelSerializer):
days_since_created = serializers.SerializerMethodField()
class Meta:
model = Task
fields = ['id', 'title', 'description', 'priority', 'is_completed',
'created_at', 'updated_at', 'assigned_to', 'days_since_created']
read_only_fields = ['created_at', 'updated_at']
def get_days_since_created(self, obj):
from django.utils import timezone
delta = timezone.now() - obj.created_at
return delta.days
def validate_title(self, value):
if len(value) < 3:
raise serializers.ValidationError("Title must be at least 3 characters long.")
return value
views.py — Implement the API logic with class-based views:
from rest_framework import viewsets, status, filters
from rest_framework.decorators import action
from rest_framework.response import Response
from django.utils import timezone
from .models import Task
from .serializers import TaskSerializer
class TaskViewSet(viewsets.ModelViewSet):
queryset = Task.objects.all()
serializer_class = TaskSerializer
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ['title', 'description']
ordering_fields = ['created_at', 'priority']
@action(detail=True, methods=['post'])
def mark_complete(self, request, pk=None):
task = self.get_object()
task.is_completed = True
task.save()
return Response({'status': 'Task marked as complete', 'task_id': task.id})
@action(detail=False, methods=['get'])
def overdue(self, request):
overdue_tasks = self.get_queryset().filter(
is_completed=False,
created_at__lt=timezone.now() - timezone.timedelta(days=7)
)
serializer = self.get_serializer(overdue_tasks, many=True)
return Response(serializer.data)
urls.py — Wire up the router:
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import TaskViewSet
router = DefaultRouter()
router.register(r'tasks', TaskViewSet, basename='task')
urlpatterns = [
path('api/', include(router.urls)),
]
Django Best Practices
- Use custom user models from the start: Even if you don't need extra fields now, creating a custom user model (
AbstractUser) on day one saves painful migrations later. - Keep business logic in models and services: Fat models, thin views. Extract complex logic into service classes or model methods rather than stuffing everything into view functions.
- Leverage Django's testing framework: Write tests using
django.test.TestCaseandpytest-django. Test models, views, and integration points. Django's test client simulates full HTTP requests. - Use
select_relatedandprefetch_related: Avoid the N+1 query problem by understanding when to use these ORM methods for eager loading of related objects. - Configure logging and monitoring early: Set up Django's logging framework, integrate Sentry for error tracking, and use Django Debug Toolbar during development to catch slow queries.
FastAPI: The High-Performance API Framework
What FastAPI Is
FastAPI is a modern Python web framework for building APIs with Python 3.8+ type hints. It's built on top of Starlette (for the web parts) and Pydantic (for the data validation and serialization). FastAPI automatically generates interactive API documentation (OpenAPI/Swagger UI), validates request and response data at runtime, and supports asynchronous programming with async/await natively. It matters because it achieves near-Node.js and Go-level throughput while maintaining the developer ergonomics of Python, making it the go-to choice for high-concurrency microservices and real-time applications.
Why FastAPI Matters
In the age of microservices, WebSockets, and server-sent events, frameworks must be fast, typed, and self-documenting. FastAPI delivers all three. Its automatic OpenAPI schema generation means your API is documented the moment you define it—no separate Swagger configuration needed. Pydantic models provide runtime validation without manual checks, catching malformed payloads before they reach your business logic. The async support means you can handle thousands of concurrent connections with minimal resources, making it ideal for I/O-bound workloads like proxying requests, streaming data, or handling many long-lived WebSocket connections simultaneously.
Getting Started with FastAPI
Install FastAPI with an ASGI server:
pip install fastapi uvicorn[standard]
Run the development server:
uvicorn main:app --reload
Practical Example: A Real-Time Chat API with WebSockets
Let's build a complete chat application with REST endpoints for message history and a WebSocket endpoint for real-time communication.
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from pydantic import BaseModel, Field
from typing import List, Dict, Optional
from datetime import datetime
import asyncio
import uuid
app = FastAPI(title="Real-Time Chat API", version="1.0.0")
# --- Models ---
class Message(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
username: str
content: str
timestamp: datetime = Field(default_factory=datetime.utcnow)
class MessageCreate(BaseModel):
content: str = Field(..., min_length=1, max_length=500)
class UserLogin(BaseModel):
username: str
password: str
# --- In-memory storage (use a proper DB in production) ---
message_history: List[Message] = []
connected_clients: Dict[str, WebSocket] = {}
# --- REST Endpoints ---
@app.post("/api/login", response_model=dict)
async def login(user: UserLogin):
"""Simulate authentication. In production, verify credentials against a database."""
if len(user.password) < 3:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
return {"access_token": f"fake-token-{user.username}", "token_type": "bearer"}
@app.get("/api/messages", response_model=List[Message])
async def get_messages(skip: int = 0, limit: int = 50):
"""Retrieve message history with pagination."""
return message_history[skip : skip + limit]
@app.delete("/api/messages/{message_id}")
async def delete_message(message_id: str):
"""Delete a specific message by ID."""
global message_history
initial_count = len(message_history)
message_history = [m for m in message_history if m.id != message_id]
if len(message_history) == initial_count:
raise HTTPException(status_code=404, detail="Message not found")
return {"detail": "Message deleted", "message_id": message_id}
# --- WebSocket Endpoint ---
@app.websocket("/ws/{username}")
async def websocket_chat(websocket: WebSocket, username: str):
await websocket.accept()
connected_clients[username] = websocket
# Notify others that a user joined
join_msg = Message(username="system", content=f"{username} has joined the chat")
message_history.append(join_msg)
await broadcast_message(join_msg, exclude=username)
try:
while True:
data = await websocket.receive_text()
if len(data) > 500:
await websocket.send_text("Error: Message too long (max 500 characters)")
continue
msg = Message(username=username, content=data)
message_history.append(msg)
# Keep only last 1000 messages to prevent memory exhaustion
if len(message_history) > 1000:
message_history.pop(0)
# Broadcast to all connected clients
await broadcast_message(msg)
except WebSocketDisconnect:
connected_clients.pop(username, None)
leave_msg = Message(username="system", content=f"{username} has left the chat")
message_history.append(leave_msg)
await broadcast_message(leave_msg)
async def broadcast_message(message: Message, exclude: Optional[str] = None):
"""Send a message to all connected clients, optionally excluding one."""
disconnected = []
for username, ws in connected_clients.items():
if username == exclude:
continue
try:
await ws.send_text(message.json())
except Exception:
disconnected.append(username)
# Clean up disconnected clients
for username in disconnected:
connected_clients.pop(username, None)
# --- Startup Event ---
@app.on_event("startup")
async def startup_event():
print("Chat server started. Visit http://localhost:8000/docs for API documentation.")
print(f"WebSocket endpoint available at ws://localhost:8000/ws/{{username}}")
To test this, open multiple browser tabs and run this minimal JavaScript client in each browser's console:
// Run this in your browser console
const ws = new WebSocket("ws://localhost:8000/ws/Alice");
ws.onmessage = (event) => console.log(JSON.parse(event.data));
ws.onopen = () => ws.send("Hello everyone!");
// To send more messages: ws.send("Your message here")
FastAPI Best Practices
- Use dependency injection with
Depends: FastAPI's dependency system is powerful. Use it for authentication checks, database session management, and permission verification. It keeps your endpoint functions clean and testable. - Structure projects with routers: For applications beyond a single file, use
APIRouterto organize endpoints by domain (e.g.,auth.py,users.py,items.py). Include them in the main app withapp.include_router(). - Define response models explicitly: Always set
response_modelin your path operation decorators. This ensures consistent API contracts, hides sensitive fields, and improves OpenAPI documentation accuracy. - Handle errors globally: Register exception handlers for common errors (validation errors, HTTP exceptions) to return consistent error responses across your entire API.
- Use async where it benefits you: Don't blindly make everything async. Async shines for I/O-bound operations (database queries, HTTP calls, file reads). For CPU-bound work, use
defendpoints (which run in a thread pool) or offload to background tasks withBackgroundTasks.
Head-to-Head Comparison
When to Choose Each Framework
The decision tree is surprisingly straightforward once you map your project's primary requirements to each framework's core strengths.
- Choose JAX when: You're doing machine learning research, need automatic differentiation through complex numerical code, require GPU/TPU acceleration, or are implementing algorithms from scratch (e.g., custom optimizers, probabilistic programming, physics simulations). JAX excels at the intersection of high-level Python expressiveness and low-level hardware performance.
- Choose Django when: You're building a full-featured web application that needs an admin interface, user authentication, database migrations, and template-rendered pages. Django shines for content-heavy sites, e-commerce platforms, internal tools, and any project where time-to-market matters and you want a well-tested, secure foundation.
- Choose FastAPI when: You're building a high-throughput API, a microservice, a WebSocket-based real-time service, or any system where async I/O and automatic OpenAPI documentation are priorities. FastAPI is the right pick when you need Python's developer experience but can't afford the performance overhead of traditional synchronous frameworks.
Side-by-Side Feature Matrix
The table below summarizes key characteristics across dimensions that matter in production:
| Dimension | JAX | Django | FastAPI |
|---|---|---|---|
| Primary Domain | Numerical computing, ML research | Full-stack web applications | High-performance APIs, microservices |
| Concurrency Model | XLA-compiled, parallel via pmap | Sync (WSGI), async views in 4.x+ | Async-first (ASGI), sync fallback |
| Automatic Documentation | N/A (not a web framework) | Manual (DRF provides Browsable API) | Automatic OpenAPI + Swagger UI |
| Database ORM | None (use external libraries) | Built-in, powerful, migrations | None (use SQLAlchemy, Tortoise, etc.) |
| Admin Interface | None | Automatic admin panel | None (use external tools) |
| Type Safety | Static shapes, JIT constraints | Optional with mypy/django-stubs | Runtime validation via Pydantic |
| Learning Curve | Steep (functional, XLA concepts) | Moderate (many built-ins) | Gentle (intuitive, modern patterns) |
| Ecosystem Maturity | Growing rapidly, research-focused | Very mature, vast third-party packages | Mature, active community, many extensions |
Combining Them in Practice
These frameworks aren't mutually exclusive. A sophisticated production system might use all three:
- JAX trains a recommendation model on GPU clusters, producing model weights and embeddings.
- FastAPI serves the model inference endpoint, handling thousands of prediction requests per second with async I/O for fetching user context from a feature store.
- Django powers the admin dashboard where business analysts review model performance metrics, manage content, and configure feature flags—all with Django's built-in auth and admin.
This architecture leverages each framework's strengths: JAX for compute-intensive ML, FastAPI for low-latency serving, and Django for the human-facing operations layer. The key insight is that you don't have to pick one framework for your entire stack—you pick the right tool for each component.
Conclusion
JAX, Django, and FastAPI each occupy distinct niches in the Python ecosystem, and understanding their differences is essential for making informed architectural decisions. JAX gives you unprecedented power for numerical and machine learning workloads through composable transformations and XLA compilation. Django provides a complete, battle-tested toolkit for building complex web applications with minimal boilerplate. FastAPI delivers modern, high-performance API development with automatic documentation and native async support. Rather than viewing them as competitors, see them as complementary tools in your Python toolbox. The best developers don't ask "which framework is best?"—they ask "which framework best solves this specific problem, right now?" Armed with the practical examples and best practices from this tutorial, you're now equipped to answer that question confidently and build production-grade systems with the framework—or combination of frameworks—that fits your needs perfectly.