Python makes building websites and applications feel less like a technical chore and more like a straightforward conversation with your computer. It translates your logic into something the web can understand. The real magic happens when you use a framework—a pre-built toolkit that handles the routine, complex parts of web development, so you can focus on what makes your project unique.
Think of it like building a house. You could forge every nail, cut every timber, and mix every batch of concrete yourself. Or, you could start with a solid foundation, pre-framed walls, and a kit of standard parts. A framework is that kit. It provides the structure, the common components, and the best practices, saving you from reinventing the wheel for every single project.
Today, I want to walk through seven of these toolkits. Each has a different personality and is suited for different kinds of jobs. I’ll explain them in simple terms, show you exactly how they work with code, and share my thoughts on when you might reach for one over another.
Let’s start with the one often described as the “web framework for perfectionists with deadlines.”
Django
If you want a complete, out-of-the-box workshop with every tool hung neatly on the wall, Django is it. Its philosophy is “batteries-included.” This means when you start a new Django project, you immediately have systems for user accounts, admin panels, database management, form handling, and security features like protection against common attacks—all working together seamlessly.
It follows a specific pattern known as MVT (Model-View-Template). This sounds fancy, but it’s just a clean way to organize your code. Your Models define what your data looks like (like a blog post with a title, author, and content). Your Views contain the logic (like “fetch the 10 latest blog posts”). Your Templates control the presentation (the HTML that displays those posts).
You don’t just have to imagine the admin panel; let’s see it work. Say we’re making a simple blog.
First, after installing Django, you’d create a project and an app within it:
django-admin startproject myblog
cd myblog
python manage.py startapp posts
Then, in posts/models.py, you define what a post is:
from django.db import models
class Post(models.Model):
title = models.CharField(max_length=200)
content = models.TextField()
published_date = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.title
After creating and applying this model’s migration to your database (python manage.py makemigrations and python manage.py migrate), you register it with the admin in posts/admin.py:
from django.contrib import admin
from .models import Post
admin.site.register(Post)
Now, run the server (python manage.py runserver), go to /admin, log in, and you have a full interface to create, read, update, and delete blog posts. You built a working data management system in about ten lines of code. That’s the Django battery pack in action. It’s fantastic for content-heavy sites like news platforms, e-commerce stores, or any application where a structured, secure backend is needed quickly.
Flask
Now, let’s step into a different workshop. Imagine a clean, empty space with a single, excellent tool rack. You pick only the tools you need for the project in front of you. This is Flask. It’s a microframework. It doesn’t assume what you’re building. It gives you the absolute basics—routing URLs to functions, handling requests and responses—and then gets out of your way.
This freedom is its greatest strength. You start small and add only the libraries you choose for databases, form validation, or user authentication. This leads to lightweight, highly customized applications.
The classic “Hello World” in Flask is beautifully minimal, as you’ve seen:
from flask import Flask
app = Flask(__name__)
@app.route('/')
def home():
return 'Welcome to Flask'
if __name__ == '__main__':
app.run(debug=True)
But its real power is in how it grows. Let’s make a slightly more useful example: a tiny API that returns a list of items in JSON format.
from flask import Flask, jsonify
app = Flask(__name__)
# A simple in-memory "database"
items = [
{"id": 1, "name": "Item One"},
{"id": 2, "name": "Item Two"}
]
@app.route('/api/items', methods=['GET'])
def get_items():
return jsonify(items)
@app.route('/api/items/<int:item_id>', methods=['GET'])
def get_item(item_id):
item = next((i for i in items if i['id'] == item_id), None)
if item is None:
return jsonify({"error": "Item not found"}), 404
return jsonify(item)
if __name__ == '__main__':
app.run(debug=True)
Save this as app.py and run it with python app.py. Visit http://127.0.0.1:5000/api/items in your browser, and you’ll see your JSON data. With about 20 lines, you have a working read-only API. Flask is my go-to for small web services, prototypes, APIs that don’t need Django’s full structure, or when I need to integrate a web interface into an existing system in a non-intrusive way.
FastAPI
If you’re building modern APIs, especially ones that need to be fast and automatically documented, FastAPI feels like stepping into the future. It’s built on top of standard Python type hints, which you use anyway to write clearer code. FastAPI takes these hints and uses them to automatically validate incoming data, convert data types, and generate interactive API documentation.
The speed is a key feature. It’s built on Starlette (for the web toolkit) and Pydantic (for the data validation), which makes it incredibly fast, rivaling frameworks in Node.js and Go. It also has first-class support for asynchronous code (async/await), allowing your application to efficiently handle many tasks that spend time waiting, like calling other APIs or reading from a database.
Let’s rebuild our simple items API with FastAPI to see the difference.
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Optional, List
app = FastAPI()
# Define what an "Item" looks like using a Pydantic model
class Item(BaseModel):
id: int
name: str
description: Optional[str] = None
# Our in-memory database
items_db = [
Item(id=1, name="Hammer", description="A sturdy hammer"),
Item(id=2, name="Nails", description="A box of nails")
]
@app.get("/items/", response_model=List[Item])
async def read_items():
return items_db
@app.get("/items/{item_id}", response_model=Item)
async def read_item(item_id: int):
for item in items_db:
if item.id == item_id:
return item
raise HTTPException(status_code=404, detail="Item not found")
@app.post("/items/", response_model=Item)
async def create_item(item: Item):
# In a real app, you'd save to a database here
items_db.append(item)
return item
Run this with uvicorn main:app --reload (assuming you saved it as main.py). Now, go to http://127.0.0.1:8000/docs. You are greeted with a fully interactive Swagger UI documentation page, generated automatically. You can click the “POST /items/” endpoint, click “Try it out,” input JSON for a new item, and execute it directly from your browser. The validation is automatic; try sending a string for the id field, and it will politely reject the request. This immediate feedback loop is a game-changer for development. I use FastAPI for any new API project where performance, clean code, and automatic documentation are priorities.
Pyramid
Pyramid is the thoughtful, flexible craftsperson of the group. It doesn’t force a “micro” or “full-stack” identity. Instead, it operates on a “pay only for what you need” principle. You can start with a single-file application that looks as simple as Flask, and then gradually adopt more of Pyramid’s structured patterns as your project expands into something large and complex.
It shines in its routing flexibility. You can use simple URL mapping like other frameworks, or you can use a powerful feature called “traversal,” which maps URLs to a tree of objects in your code—a natural fit for content-heavy sites with hierarchical data.
Here’s a Pyramid application in a single file, similar to our Flask example:
from wsgiref.simple_server import make_server
from pyramid.config import Configurator
from pyramid.response import Response
def hello_world(request):
return Response('Hello from Pyramid!')
if __name__ == '__main__':
with Configurator() as config:
config.add_route('hello', '/')
config.add_view(hello_world, route_name='hello')
app = config.make_wsgi_app()
server = make_server('0.0.0.0', 6543, app)
server.serve_forever()
Run this script, and visit http://127.0.0.1:6543. You have a working web app. But where Pyramid shows its depth is in scaling up. As your project grows, you can organize views into separate modules, use a more advanced persistence system, and leverage its extensive set of add-ons. It’s a framework that grows with you, without surprising changes in architecture. I think of Pyramid for long-term projects where the requirements might start simple but are expected to evolve significantly, and you need a framework that won’t get in the way or become inadequate.
Bottle
Bottle is the essence of simplicity. It’s a true microframework distributed as a single bottle.py file. It has zero dependencies outside the Python Standard Library. You can drop it into any project, and it just works. This makes it perfect for ultra-lightweight web services, quick scripts that need a web interface, or learning the fundamentals of web frameworks without any setup complexity.
Let’s create a small service that calculates something. Here’s a full application:
from bottle import route, run, template
@route('/greet/<name>')
def greet(name):
return template('<b>Hello, {{name}}!</b>', name=name)
@route('/square/<number:int>')
def calculate_square(number):
return f"The square of {number} is {number * number}."
if __name__ == '__main__':
run(host='localhost', port=8080, debug=True)
Save this, run it, and visit http://localhost:8080/greet/YourName and http://localhost:8080/square/5. That’s it. It includes routing, simple templating, and a development server. There’s no project structure to set up, no configuration files. I’ve used Bottle to create tiny dashboard endpoints for system monitoring scripts or to put a quick REST interface on a hardware prototype. It’s the digital equivalent of a pocket knife—small, self-contained, and surprisingly useful.
CherryPy
CherryPy has a unique and elegant goal: to let you build web applications as if you were writing any regular Python program. In CherryPy, you create classes whose methods become your web endpoints. A request to a URL is treated like a call to a method on an object. It hides the underlying HTTP protocol more completely than other frameworks.
It’s a minimalist framework in spirit but comes with production-ready features like a robust HTTP server, session management, caching, and encoding tools, all built-in.
Here’s how you structure a simple CherryPy app:
import cherrypy
class HelloWorld:
@cherrypy.expose
def index(self):
return "This is the homepage."
@cherrypy.expose
def greet(self, name="Guest"):
return f"Hello, {name}!"
@cherrypy.expose
def add(self, a=0, b=0):
try:
result = int(a) + int(b)
return f"The sum of {a} and {b} is {result}."
except ValueError:
return "Please provide integer values."
if __name__ == '__main__':
cherrypy.quickstart(HelloWorld())
Run this script. Visit http://127.0.0.1:8080/ to see the index. Go to http://127.0.0.1:8080/greet?name=Alice to be greeted. Try http://127.0.0.1:8080/add?a=5&b=3. The parameters from the URL are automatically passed to your method. This object-oriented, “expose what you need” approach makes it feel very natural if your brain works in terms of objects and methods. It’s great for building medium-sized applications where you want a clean, object-oriented design without a lot of external ceremony.
Tornado
Finally, we have Tornado, the specialist. While other frameworks can handle various tasks, Tornado is engineered for one thing in particular: handling long-lived, many simultaneous connections. It’s a non-blocking, asynchronous network library at its core. This makes it perfect for real-time web services where you might have thousands of users connected at once, like chat applications, live dashboards, or WebSocket-based games.
Its event loop model allows it to manage many connections in a single thread, without waiting for one operation to finish before starting another. This is different from the traditional request-response model.
Here is a basic Tornado app with a delayed response to demonstrate its async nature:
import tornado.ioloop
import tornado.web
import asyncio
class MainHandler(tornado.web.RequestHandler):
async def get(self):
# Simulate a slow I/O operation (e.g., a database query)
await asyncio.sleep(2)
self.write("Hello, Tornado! This response was delayed.")
class WebSocketHandler(tornado.websocket.WebSocketHandler):
def open(self):
print("WebSocket opened")
self.write_message("Connected to server!")
def on_message(self, message):
self.write_message(f"You said: {message}")
def on_close(self):
print("WebSocket closed")
def make_app():
return tornado.web.Application([
(r"/", MainHandler),
(r"/ws", WebSocketHandler),
])
if __name__ == "__main__":
app = make_app()
app.listen(8888)
print("Server starting on http://127.0.0.1:8888")
tornado.ioloop.IOLoop.current().start()
Run this and visit http://127.0.0.1:8888. You’ll notice a two-second delay, but importantly, the server can handle other requests during that time because it uses async/await. The WebSocket endpoint at /ws is built-in and ready for real-time, two-way communication. You would use Tornado when your primary technical requirement is handling high levels of concurrency and real-time features. For standard CRUD websites, it might be overkill, but for its specialty, it’s exceptional.
Choosing the right framework isn’t about finding the “best” one. It’s about matching the tool to the task. Do you need a complete, secure system fast? Choose Django. Building a lean, custom API or microservice? Flask or FastAPI are excellent. Need a simple script online? Bottle has you covered. Working on a large, evolving application? Consider Pyramid. Prefer an object-oriented style? CherryPy feels natural. Building a real-time system with thousands of connections? Tornado is built for that.
Each of these frameworks takes Python’s clarity and turns it into a structured approach for talking to the web. They handle the complex protocols so you can focus on writing the code that matters for your idea. Start with a simple example, like the ones here, and you’ll be surprised how quickly you can make the web respond.