Introduction: Understanding Python Web Frameworks
Python's web ecosystem is rich and diverse, offering developers multiple frameworks tailored to different use cases. Among the most prominent are Django, Tornado, and FastAPI. Each framework was born from different philosophies and solves different problems, which makes choosing between them a critical architectural decision that affects everything from performance to developer productivity.
Django, created in 2003, is a "batteries-included" framework designed for rapid development of content-heavy applications. Tornado, open-sourced by FriendFeed in 2009, was built to handle thousands of simultaneous long-lived connections. FastAPI, released in 2018, is the modern contender that leverages Python type hints and asynchronous programming to deliver exceptional performance and developer experience.
This tutorial explores each framework in depth, provides practical code examples, compares their strengths and weaknesses, and offers best practices to help you make an informed decision for your next project.
Why Framework Choice Matters
Selecting the right framework is not merely a matter of preference — it has long-term consequences for your application's scalability, maintainability, and team velocity. The wrong choice can lead to performance bottlenecks, unnecessary complexity, or the need for a costly rewrite.
Performance Implications
Frameworks differ dramatically in how they handle concurrent requests. A synchronous framework like Django (in its default mode) processes one request per worker, while asynchronous frameworks like Tornado and FastAPI can handle many concurrent I/O-bound operations within a single process. For applications with heavy I/O — such as those calling external APIs, querying databases, or streaming data — this difference can mean the difference between needing 50 servers or 5.
Developer Productivity
Django's ORM, admin panel, authentication system, and migrations let teams ship features quickly. FastAPI's automatic documentation generation and type validation reduce boilerplate. Tornado's minimalism gives developers full control but requires more manual setup. Understanding these trade-offs helps align framework choice with team capabilities and project timelines.
Ecosystem and Longevity
Django has a massive ecosystem with thousands of reusable packages. FastAPI's ecosystem is growing rapidly but is younger. Tornado's ecosystem is smaller and more specialized. Choosing a framework with a healthy ecosystem ensures access to community support, third-party integrations, and long-term maintenance.
Django: The Batteries-Included Framework
Django follows the "batteries-included" philosophy, providing everything needed to build a complete web application out of the box. It includes an ORM, authentication system, admin interface, form handling, templating engine, and migration system. Django is ideal for content-heavy applications, CMS platforms, e-commerce sites, and any project where rapid development and convention over configuration are valued.
Key Features of Django
- ORM: A powerful Object-Relational Mapper supporting PostgreSQL, MySQL, SQLite, and Oracle.
- Admin Interface: Automatically generated admin panel for managing application data.
- Authentication: Built-in user authentication, authorization, and session management.
- Migrations: Schema evolution through version-controlled migration files.
- Security: Built-in protection against CSRF, XSS, SQL injection, and clickjacking.
- Async Support: Since Django 3.1, asynchronous views and middleware are supported.
Setting Up a Django Project
Let's create a simple Django application with a model, view, and URL configuration. First, install Django and create a new project:
# Install Django
pip install django
# Create a new project
django-admin startproject myproject
cd myproject
# Create a new app
python manage.py startapp blog
# Add 'blog' to INSTALLED_APPS in myproject/settings.py
Defining a Model
In blog/models.py, define a simple blog post model:
from django.db import models
from django.contrib.auth.models import User
class Post(models.Model):
title = models.CharField(max_length=200)
slug = models.SlugField(unique=True)
content = models.TextField()
author = models.ForeignKey(User, on_delete=models.CASCADE)
published = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return self.title
After defining the model, create and apply migrations:
python manage.py makemigrations
python manage.py migrate
Creating Views and URLs
In blog/views.py, create both a function-based view and a class-based view:
from django.http import JsonResponse
from django.views import View
from django.views.decorators.http import require_GET
from .models import Post
@require_GET
def post_list(request):
"""Function-based view returning all published posts as JSON."""
posts = Post.objects.filter(published=True).values(
'id', 'title', 'slug', 'created_at'
)
return JsonResponse({'posts': list(posts)})
class PostDetailView(View):
"""Class-based view returning a single post by ID."""
def get(self, request, post_id):
try:
post = Post.objects.get(id=post_id, published=True)
return JsonResponse({
'id': post.id,
'title': post.title,
'content': post.content,
'created_at': post.created_at.isoformat(),
})
except Post.DoesNotExist:
return JsonResponse({'error': 'Post not found'}, status=404)
In blog/urls.py, wire up the views:
from django.urls import path
from .views import post_list, PostDetailView
urlpatterns = [
path('posts/', post_list, name='post_list'),
path('posts/<int:post_id>/', PostDetailView.as_view(), name='post_detail'),
]
Include these URLs in myproject/urls.py:
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include('blog.urls')),
]
Async Views in Django
Modern Django supports asynchronous views, useful for I/O-bound operations:
import asyncio
from django.http import JsonResponse
async def async_post_list(request):
"""Async view demonstrating non-blocking I/O."""
await asyncio.sleep(0.1) # Simulate I/O operation
posts = await Post.objects.filter(published=True).afirst()
return JsonResponse({'status': 'async view works'})
Django Best Practices
- Use class-based views for reusable logic, but prefer function-based views for simple endpoints.
- Keep business logic in models or dedicated service modules, not in views.
- Use Django REST Framework for building APIs rather than raw JsonResponse.
- Always use environment variables for sensitive settings via
django-environ. - Write tests using Django's built-in test client and TestCase classes.
- Use
select_relatedandprefetch_relatedto avoid N+1 query problems. - Enable connection pooling for production database connections.
Tornado: The Async Pioneer
Tornado is a Python web framework and asynchronous networking library. It was originally developed at FriendFeed and open-sourced after Facebook acquired the company. Tornado's defining characteristic is its non-blocking I/O model, which allows it to handle thousands of simultaneous connections efficiently. This makes it particularly well-suited for long polling, WebSockets, and real-time services.
Key Features of Tornado
- Non-blocking I/O: Built on an event loop using
asyncio(since Tornado 5.0). - WebSockets: First-class support for WebSocket connections.
- Template Engine: Lightweight, fast templating system.
- Authentication: Pluggable authentication with support for OAuth, OpenID, and more.
- Minimalism: No ORM or form handling — you bring your own tools.
- Streaming: Native support for streaming responses and handling large uploads.
Setting Up a Tornado Application
# Install Tornado
pip install tornado
Here is a complete Tornado application with multiple handlers, including a WebSocket handler:
import tornado.ioloop
import tornado.web
import tornado.websocket
import json
from tornado.options import define, options, parse_command_line
define('port', default=8888, help='run on the given port', type=int)
# In-memory data store for demonstration
posts = [
{'id': 1, 'title': 'Hello Tornado', 'content': 'My first post'},
{'id': 2, 'title': 'Async Programming', 'content': 'Non-blocking I/O explained'},
]
class MainHandler(tornado.web.RequestHandler):
"""Simple JSON endpoint returning all posts."""
def get(self):
self.set_header('Content-Type', 'application/json')
self.write(json.dumps({'posts': posts}))
class PostDetailHandler(tornado.web.RequestHandler):
"""Handler returning a single post by ID."""
def get(self, post_id):
post = next((p for p in posts if p['id'] == int(post_id)), None)
if post is None:
self.set_status(404)
self.write({'error': 'Post not found'})
return
self.write(post)
class CreatePostHandler(tornado.web.RequestHandler):
"""Handler for creating a new post via POST."""
def post(self):
try:
data = json.loads(self.request.body)
new_post = {
'id': len(posts) + 1,
'title': data.get('title', ''),
'content': data.get('content', ''),
}
posts.append(new_post)
self.set_status(201)
self.write(new_post)
except json.JSONDecodeError:
self.set_status(400)
self.write({'error': 'Invalid JSON'})
class EchoWebSocketHandler(tornado.websocket.WebSocketHandler):
"""WebSocket handler that echoes messages back to clients."""
connected_clients = set()
def open(self):
self.connected_clients.add(self)
print(f'WebSocket opened. Total clients: {len(self.connected_clients)}')
def on_message(self, message):
# Broadcast message to all connected clients
for client in self.connected_clients:
client.write_message(f'Echo: {message}')
def on_close(self):
self.connected_clients.discard(self)
print(f'WebSocket closed. Total clients: {len(self.connected_clients)}')
class AsyncHandler(tornado.web.RequestHandler):
"""Demonstrates asynchronous I/O with a simulated delay."""
async def get(self):
import asyncio
await asyncio.sleep(1) # Simulate I/O-bound operation
self.write({'message': 'Async response after 1 second delay'})
def make_app():
return tornado.web.Application([
(r'/', MainHandler),
(r'/posts/([0-9]+)', PostDetailHandler),
(r'/posts/create', CreatePostHandler),
(r'/ws', EchoWebSocketHandler),
(r'/async', AsyncHandler),
], debug=True)
if __name__ == '__main__':
parse_command_line()
app = make_app()
app.listen(options.port)
print(f'Tornado server running on http://localhost:{options.port}')
tornado.ioloop.IOLoop.current().start()
Using Tornado with a Database
Since Tornado does not include an ORM, you typically pair it with an async database driver. Here is an example using asyncpg with PostgreSQL:
import tornado.web
import asyncpg
import json
class DatabaseMixin:
pool = None
@classmethod
async def init_pool(cls, dsn):
cls.pool = await asyncpg.create_pool(dsn)
class PostListDBHandler(tornado.web.RequestHandler, DatabaseMixin):
async def get(self):
async with self.pool.acquire() as conn:
rows = await conn.fetch(
'SELECT id, title, content FROM posts ORDER BY id'
)
posts = [dict(row) for row in rows]
self.write(json.dumps({'posts': posts}))
async def post(self):
data = json.loads(self.request.body)
async with self.pool.acquire() as conn:
row = await conn.fetchrow(
'INSERT INTO posts (title, content) VALUES ($1, $2) RETURNING id, title, content',
data['title'],
data['content'],
)
self.set_status(201)
self.write(json.dumps(dict(row)))
Tornado Best Practices
- Always use
async defhandlers for I/O-bound operations to avoid blocking the event loop. - Never call blocking operations (like
requests.get) directly — use async alternatives likehttpxoraiohttp. - Use connection pooling for database connections to avoid creating a new connection per request.
- Implement proper error handling with custom
write_errormethods for consistent API responses. - Use Tornado's
Applicationsettings to configure logging, static file serving, and template paths. - For production, run Tornado behind a reverse proxy like Nginx and use multiple processes via
tornado.process.fork_processes.
FastAPI: The Modern Async Framework
FastAPI is a modern, fast web framework for building APIs with Python 3.7+. It is built on top of Starlette (for the web parts) and Pydantic (for the data parts). FastAPI leverages Python type hints to provide automatic request validation, response serialization, and interactive API documentation. It has quickly become one of the most popular Python frameworks due to its developer-friendly design and impressive performance.
Key Features of FastAPI
- Type Hints: Uses Python type annotations for automatic validation and serialization.
- Automatic Documentation: Generates interactive Swagger UI and ReDoc documentation.
- Async Support: Native async/await support built on Starlette.
- Dependency Injection: Powerful dependency injection system for reusable logic.
- Pydantic Integration: Data validation and settings management through Pydantic models.
- Performance: Among the fastest Python frameworks, comparable to Node.js and Go.
Setting Up a FastAPI Application
# Install FastAPI and Uvicorn (ASGI server)
pip install fastapi uvicorn[standard]
Here is a complete FastAPI application demonstrating routing, Pydantic models, dependency injection, and async database access:
from fastapi import FastAPI, HTTPException, Depends, Query
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
import uvicorn
app = FastAPI(
title='Blog API',
description='A sample blog API built with FastAPI',
version='1.0.0',
)
# --- Pydantic Models ---
class PostBase(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
content: str = Field(..., min_length=1)
published: bool = False
class PostCreate(PostBase):
pass
class PostResponse(PostBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# --- In-memory storage (replace with real database in production) ---
posts_db = {}
next_id = 1
# --- Dependency for pagination ---
def pagination_params(skip: int = Query(0, ge=0), limit: int = Query(10, ge=1, le=100)):
return {'skip': skip, 'limit': limit}
# --- Routes ---
@app.get('/posts', response_model=List[PostResponse])
async def list_posts(pagination: dict = Depends(pagination_params)):
"""Retrieve a paginated list of all published posts."""
all_posts = list(posts_db.values())
return all_posts[pagination['skip']:pagination['skip'] + pagination['limit']]
@app.get('/posts/{post_id}', response_model=PostResponse)
async def get_post(post_id: int):
"""Retrieve a single post by its ID."""
if post_id not in posts_db:
raise HTTPException(status_code=404, detail='Post not found')
return posts_db[post_id]
@app.post('/posts', response_model=PostResponse, status_code=201)
async def create_post(post: PostCreate):
"""Create a new blog post."""
global next_id
now = datetime.utcnow()
new_post = {
'id': next_id,
'title': post.title,
'content': post.content,
'published': post.published,
'created_at': now,
'updated_at': now,
}
posts_db[next_id] = new_post
next_id += 1
return new_post
@app.put('/posts/{post_id}', response_model=PostResponse)
async def update_post(post_id: int, post: PostCreate):
"""Update an existing post."""
if post_id not in posts_db:
raise HTTPException(status_code=404, detail='Post not found')
posts_db[post_id].update({
'title': post.title,
'content': post.content,
'published': post.published,
'updated_at': datetime.utcnow(),
})
return posts_db[post_id]
@app.delete('/posts/{post_id}', status_code=204)
async def delete_post(post_id: int):
"""Delete a post by its ID."""
if post_id not in posts_db:
raise HTTPException(status_code=404, detail='Post not found')
del posts_db[post_id]
if __name__ == '__main__':
uvicorn.run('main:app', host='0.0.0.0', port=8000, reload=True)
FastAPI with Async Database (SQLAlchemy + asyncpg)
For production applications, you will want to use a real database. Here is how to integrate FastAPI with async SQLAlchemy:
from fastapi import FastAPI, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from sqlalchemy import Column, Integer, String, Boolean, DateTime, select
from pydantic import BaseModel
from datetime import datetime
DATABASE_URL = 'postgresql+asyncpg://user:password@localhost/blogdb'
engine = create_async_engine(DATABASE_URL, echo=True)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
Base = declarative_base()
app = FastAPI(title='Blog API with SQLAlchemy')
# --- SQLAlchemy Model ---
class Post(Base):
__tablename__ = 'posts'
id = Column(Integer, primary_key=True, index=True)
title = Column(String(200), nullable=False)
content = Column(String, nullable=False)
published = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
# --- Pydantic Schemas ---
class PostCreate(BaseModel):
title: str
content: str
published: bool = False
class PostOut(BaseModel):
id: int
title: str
content: str
published: bool
created_at: datetime
class Config:
from_attributes = True
# --- Database Dependency ---
async def get_db():
async with async_session() as session:
try:
yield session
finally:
await session.close()
# --- Routes ---
@app.get('/posts', response_model=list[PostOut])
async def get_posts(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Post))
return result.scalars().all()
@app.post('/posts', response_model=PostOut, status_code=201)
async def create_post(post: PostCreate, db: AsyncSession = Depends(get_db)):
db_post = Post(**post.dict())
db.add(db_post)
await db.commit()
await db.refresh(db_post)
return db_post
@app.get('/posts/{post_id}', response_model=PostOut)
async def get_post(post_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Post).where(Post.id == post_id))
post = result.scalar_one_or_none()
if post is None:
raise HTTPException(status_code=404, detail='Post not found')
return post
FastAPI Best Practices
- Use Pydantic models for all request and response schemas to ensure type safety.
- Leverage dependency injection for database sessions, authentication, and shared logic.
- Organize code into routers using
APIRouterfor modular, maintainable applications. - Use background tasks or Celery for long-running operations to avoid blocking the event loop.
- Always run FastAPI behind an ASGI server like Uvicorn or Hypercorn in production.
- Use
lifespanevents for startup and shutdown logic like database connection pooling. - Write tests using
httpx.AsyncClientwith FastAPI'sTestClient. - Configure CORS middleware properly for cross-origin requests.
Head-to-Head Comparison
Performance
FastAPI and Tornado both excel at handling concurrent I/O-bound requests. FastAPI, built on Starlette, consistently benchmarks as one of the fastest Python frameworks available. Tornado also performs exceptionally well, particularly for long-lived connections. Django, in its default synchronous mode, is slower for I/O-bound workloads but performs adequately for CPU-bound tasks and database-heavy operations. Django's async support has improved significantly but is still maturing compared to FastAPI and Tornado.
Developer Experience
FastAPI offers the best modern developer experience with automatic documentation, type validation, and excellent editor support. Django provides a smooth experience for traditional web applications with its admin panel and ORM. Tornado has a steeper learning curve due to its minimalism and requires developers to assemble their own stack of tools.
Ecosystem and Community
Django has the largest and most mature ecosystem, with thousands of packages, extensive documentation, and a large community. FastAPI's community is growing rapidly and has strong momentum. Tornado has a smaller but dedicated community, particularly among developers building real-time applications.
Use Case Suitability
- Django: Best for content-heavy web applications, CMS platforms, e-commerce, admin dashboards, and projects requiring rapid development with a full-featured framework.
- Tornado: Best for real-time applications, WebSockets, long polling, chat systems, and services requiring thousands of concurrent long-lived connections.
- FastAPI: Best for building RESTful APIs, microservices, machine learning model serving, and any project where performance, type safety, and automatic documentation are priorities.
Learning Curve
Django has a moderate learning curve — its conventions are well-documented, but the framework is large. FastAPI is relatively easy to learn if you are familiar with Python type hints. Tornado requires understanding of asynchronous programming concepts and event loops, which can be challenging for developers new to async Python.
Decision Framework
To help you choose, consider these questions about your project:
- Is your application primarily an API? Choose FastAPI for its automatic documentation and type validation.
- Do you need real-time features like WebSockets? Choose Tornado or FastAPI — both handle this well.
- Do you need an admin panel and ORM out of the box? Choose Django.
- Is rapid prototyping a priority? Choose Django for full-stack apps or FastAPI for APIs.
- Are you building microservices? Choose FastAPI for its lightweight nature and performance.
- Do you have a team experienced with async Python? FastAPI and Tornado are great choices; otherwise, Django's synchronous mode is more approachable.
- Is long-term maintainability critical? Django's mature ecosystem and conventions support long-term projects well.
Conclusion
Choosing between Tornado, Django, and FastAPI ultimately depends on your project requirements, team expertise, and long-term goals. Django remains the go-to framework for full-featured web applications where rapid development, an admin interface, and a mature ecosystem are paramount. Tornado excels in specialized scenarios involving real-time communication and massive concurrency, making it ideal for chat applications, notification systems, and streaming services. FastAPI has emerged as the leading choice for modern API development, combining exceptional performance with an outstanding developer experience through type hints, automatic documentation, and native async support. Regardless of your choice, following framework-specific best practices — from proper async handling in Tornado to leveraging Django's ORM efficiently to maximizing FastAPI's dependency injection — will ensure your application is maintainable, scalable, and performant. The Python web ecosystem is fortunate to offer such capable and distinct options, and understanding their strengths empowers you to build the right solution for the problem at hand.