Bottle Microframework — Deep Dive
Routing engine internals
Bottle’s router compiles URL patterns into regular expressions at startup. Each route definition is parsed and converted:
from bottle import Bottle, request, response
app = Bottle()
@app.route("/api/items/<item_id:int>")
def get_item(item_id):
"""item_id is automatically converted to int."""
return {"id": item_id, "name": f"Item {item_id}"}
@app.route("/files/<filepath:path>")
def serve_file(filepath):
"""filepath matches everything including slashes."""
return static_file(filepath, root="/var/www/files")
@app.route("/users/<name:re:[a-z]{3,20}>")
def get_user(name):
"""name must match the regex: 3-20 lowercase letters."""
return {"username": name}
Custom filters extend the routing system:
app.router.add_filter("uuid", lambda config:
(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",
str, str))
@app.route("/orders/<order_id:uuid>")
def get_order(order_id):
return {"order": order_id}
The filter returns a tuple of (regex_pattern, incoming_converter, outgoing_converter). This lets you define reusable URL parameter types.
Request handling in detail
Bottle uses thread-local proxy objects for request and response:
from bottle import request, response, abort
@app.post("/api/items")
def create_item():
# Content type detection
if request.content_type != "application/json":
abort(415, "JSON required")
# Body parsing
data = request.json # Auto-parsed from JSON body
# Query parameters
page = request.query.get("page", default=1, type=int)
# Headers
auth = request.headers.get("Authorization", "")
# Cookies
session_id = request.get_cookie("session", secret="my-secret")
# File uploads
upload = request.files.get("avatar")
if upload:
upload.save(f"/uploads/{upload.filename}")
# Set response properties
response.content_type = "application/json"
response.set_cookie("last_item", data["name"], httponly=True)
response.status = 201
return {"created": True, "name": data["name"]}
The thread-local approach means each request gets its own request and response objects without passing them as arguments — similar to Flask’s design.
Plugin system architecture
Bottle plugins implement a specific interface:
class DatabasePlugin:
"""Injects a database connection into handler kwargs."""
name = "database"
api = 2 # Plugin API version
def __init__(self, dsn):
self.dsn = dsn
self.pool = None
def setup(self, app):
"""Called once when plugin is installed."""
self.pool = create_pool(self.dsn)
# Ensure no conflicting plugins
for other in app.plugins:
if hasattr(other, "name") and other.name == self.name:
raise RuntimeError("Database plugin already installed")
def apply(self, callback, route):
"""Wraps each route handler."""
import inspect
# Only inject if handler accepts 'db' parameter
params = inspect.signature(callback).parameters
if "db" not in params:
return callback
def wrapper(*args, **kwargs):
conn = self.pool.get()
try:
kwargs["db"] = conn
result = callback(*args, **kwargs)
conn.commit()
return result
except Exception:
conn.rollback()
raise
finally:
self.pool.release(conn)
return wrapper
def close(self):
"""Called when plugin is uninstalled."""
if self.pool:
self.pool.close()
# Install
app.install(DatabasePlugin("sqlite:///app.db"))
# Usage - db is auto-injected
@app.get("/users")
def list_users(db):
return {"users": db.execute("SELECT * FROM users").fetchall()}
The key insight is the apply method — it wraps each route handler, and the inspect.signature check means the plugin only activates for handlers that actually request the dependency.
Error handling
Bottle provides multiple error handling mechanisms:
from bottle import HTTPError, HTTPResponse
# Custom error pages
@app.error(404)
def error404(error):
return {"error": "not found", "path": request.path}
@app.error(500)
def error500(error):
logger.exception("Server error")
return {"error": "internal server error"}
# Raising specific errors
@app.get("/items/<id:int>")
def get_item(id):
item = db.find(id)
if not item:
raise HTTPError(404, f"Item {id} not found")
return item
# Custom responses with full control
@app.get("/redirect")
def do_redirect():
raise HTTPResponse(
body="",
status=302,
headers={"Location": "/new-location"}
)
Hooks for cross-cutting concerns
Bottle provides before/after request hooks:
import time
@app.hook("before_request")
def before():
request._start_time = time.perf_counter()
@app.hook("after_request")
def after():
elapsed = time.perf_counter() - request._start_time
response.headers["X-Response-Time"] = f"{elapsed:.4f}"
# Authentication hook
@app.hook("before_request")
def check_auth():
public_paths = ["/", "/health", "/login"]
if request.path not in public_paths:
token = request.headers.get("Authorization")
if not verify_token(token):
abort(401, "Invalid token")
Template engine
Bottle’s SimpleTemplate supports inline Python:
from bottle import template, jinja2_template
@app.get("/dashboard")
def dashboard():
items = get_items()
return template("""
<h1>Dashboard</h1>
<ul>
% for item in items:
<li>{{item['name']}} - ${{item['price']:.2f}}</li>
% end
</ul>
""", items=items)
# Or use Jinja2
@app.get("/report")
def report():
return jinja2_template("report.html", data=get_report_data())
Production deployment
Bottle is a WSGI application, so it deploys with any WSGI server:
# gunicorn
# gunicorn myapp:app -w 4 -b 0.0.0.0:8000
# waitress (Windows-compatible)
from waitress import serve
serve(app, host="0.0.0.0", port=8000)
# CherryPy
from cheroot.wsgi import Server
Server(("0.0.0.0", 8000), app).start()
For production behind nginx:
upstream bottle_backend {
server 127.0.0.1:8000;
}
server {
listen 80;
server_name api.example.com;
location / {
proxy_pass http://bottle_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /static {
alias /var/www/static;
expires 30d;
}
}
Structuring larger Bottle apps
While Bottle doesn’t have blueprints, you can organize larger apps with sub-applications:
# api/users.py
from bottle import Bottle
users_app = Bottle()
@users_app.get("/")
def list_users():
return {"users": []}
@users_app.get("/<user_id:int>")
def get_user(user_id):
return {"id": user_id}
# api/orders.py
from bottle import Bottle
orders_app = Bottle()
@orders_app.get("/")
def list_orders():
return {"orders": []}
# main.py
from bottle import Bottle
from api.users import users_app
from api.orders import orders_app
app = Bottle()
app.mount("/api/users", users_app)
app.mount("/api/orders", orders_app)
The mount() method attaches sub-applications at a URL prefix, providing organization similar to Flask blueprints. Each sub-app is a complete Bottle instance with its own routes, plugins, and error handlers.
Embedded use case: Raspberry Pi
One of Bottle’s strongest use cases is embedded systems:
#!/usr/bin/env python3
"""Simple sensor dashboard on Raspberry Pi."""
import bottle
import json
app = bottle.Bottle()
def read_temperature():
with open("/sys/class/thermal/thermal_zone0/temp") as f:
return int(f.read()) / 1000
@app.get("/")
def index():
return bottle.template("<h1>Temp: {{temp}}°C</h1>", temp=read_temperature())
@app.get("/api/temperature")
def api_temp():
return {"celsius": read_temperature()}
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)
Copy this file and bottle.py to a Raspberry Pi. Run it. You have a web-accessible temperature dashboard with zero installation.
The one thing to remember: Bottle’s single-file, zero-dependency design isn’t a limitation — it’s a deliberate architectural choice that makes it the ideal framework for embedded systems, quick prototypes, and any situation where deployment simplicity matters more than framework features.
See Also
- Python Aiohttp Client Understand Aiohttp Client through a practical analogy so your Python decisions become faster and clearer.
- Python Api Client Design Why building your own API client in Python is like creating a TV remote that only has the buttons you actually need.
- Python Api Documentation Swagger Swagger turns your Python API into an interactive playground where anyone can click buttons to try it out — no coding required.
- Python Api Mocking Responses Why testing with fake API responses is like rehearsing a play with stand-ins before the real actors show up.
- Python Api Pagination Clients Why APIs send data in pages, and how Python handles it — like reading a book one chapter at a time instead of swallowing the whole thing.