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.

pythonweb-frameworksmicroframeworkbottle

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.