← Back to DevBytes

Tornado vs Django vs FastAPI: Framework Comparison

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

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

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

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

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

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

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

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:

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles