Introduction to Bottle as a Migration Target
Bottle is a minimalist WSGI web framework for Python. It is distributed as a single module with zero dependencies outside the standard library, making it an exceptionally lightweight and portable choice for web applications. For teams maintaining applications built on legacy frameworks—such as older versions of Django, TurboGears, web.py, or even custom in-house micro-frameworks—migrating to Bottle offers a path toward simpler deployment, reduced overhead, and easier long-term maintenance.
What Makes Bottle Different
Unlike full-stack frameworks that ship with ORMs, templating engines, form validation, and admin interfaces, Bottle strips everything down to routing, request handling, and response rendering. It includes a built-in HTTP server for development, supports multiple backend WSGI servers, and integrates seamlessly with any database library or templating system you choose. This "bring your own components" philosophy is precisely what makes it an attractive migration target: you keep only the pieces you actually need.
Key characteristics of Bottle:
- Single-file framework — the entire framework lives in
bottle.py, which you can even bundle directly in your project - Zero external dependencies — relies exclusively on Python's standard library
- Full WSGI compliance — works with Gunicorn, uWSGI, mod_wsgi, and any WSGI-compatible server
- Built-in routing — supports static, dynamic, and regex-based URL patterns with HTTP verb filtering
- Template agnostic — includes a simple built-in engine but adapters exist for Jinja2, Mako, and others
- Request/response context — thread-safe access to request data and response manipulation
Why Migrating from Legacy Frameworks Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Legacy frameworks often carry years of accumulated technical debt. They may depend on outdated Python versions, rely on unmaintained third-party packages, or enforce architectural patterns that no longer fit your application's scale. Migration is not merely about chasing the newest tool—it is about reducing risk, improving performance, and simplifying the deployment pipeline.
Common Pain Points with Legacy Frameworks
- Dependency sprawl — older frameworks frequently pull in dozens of transitive dependencies, many of which may have known security vulnerabilities or lack Python 3.12+ compatibility
- Heavy startup times — large frameworks with extensive bootstrapping can take seconds to load, which is problematic in serverless or containerized environments
- Forced conventions — some legacy frameworks impose directory structures, naming conventions, and configuration patterns that no longer match modern development practices
- Dead code weight — unused middleware, abandoned ORM integrations, and stale administrative interfaces bloat the codebase
- Deployment complexity — legacy frameworks may require specific server configurations, outdated WSGI adapters, or custom deployment scripts
The Business Case for Migration
Applications running on unsupported framework versions face real dangers: security patches stop arriving, new Python releases break compatibility, and finding developers willing to maintain the old codebase becomes increasingly expensive. Bottle's minimalism directly addresses these concerns. With a single-module core, auditing the framework for security issues is trivial. The absence of external dependencies eliminates entire categories of supply-chain risk. And the small API surface means any Python developer can become productive in hours, not weeks.
Understanding Bottle's Core Concepts
Before diving into migration, you need to internalize how Bottle structures an application. The framework revolves around four central abstractions: the Bottle application object, route decorators, the request/response proxies, and the templating layer.
The Application Object
Every Bottle application starts by instantiating a Bottle class or using the default module-level "app" object. For migration scenarios, using an explicit instance is cleaner because it allows you to run multiple applications side by side during the transition period.
from bottle import Bottle, run
# Explicit application instance — preferred for migration
app = Bottle()
@app.route('/')
def index():
return 'Hello from the migrated application!'
if __name__ == '__main__':
run(app, host='localhost', port=8080, debug=True)
Routing and HTTP Verbs
Bottle's routing system maps URL patterns to handler functions using decorators. Unlike some legacy frameworks that require centralized URL configuration files, Bottle encourages co-locating routes with their handlers. HTTP verb filtering is built-in, so a single URL pattern can dispatch to different functions based on the request method.
@app.route('/api/users', method=['GET'])
def list_users():
# Returns all users as JSON
return {'users': ['alice', 'bob', 'charlie']}
@app.route('/api/users', method=['POST'])
def create_user():
# Handles user creation
username = request.forms.get('username')
return {'created': username}
Request and Response Proxies
Bottle provides thread-local proxy objects request and response that give handler functions access to HTTP data without passing arguments explicitly. This pattern will feel familiar if you come from Flask or web.py, but differs significantly from frameworks that inject request objects through parameter passing.
from bottle import request, response
@app.route('/login', method=['POST'])
def login():
username = request.forms.get('username')
password = request.forms.get('password')
if authenticate(username, password):
response.set_cookie('session', create_session(username))
response.status = 200
return {'message': 'Logged in successfully'}
else:
response.status = 401
return {'error': 'Invalid credentials'}
Static Files and Error Handling
Bottle includes dedicated functions for serving static assets and registering error handlers. These replace the often complex static-file middleware stacks found in larger frameworks.
from bottle import static_file, error
@app.route('/static/')
def serve_static(filename):
return static_file(filename, root='./public')
@error(404)
def not_found(error):
return {'error': 'Resource not found'}, 404
@error(500)
def server_error(error):
return {'error': 'Internal server error'}, 500
Migration Strategy: Step-by-Step Approach
A successful migration from a legacy framework to Bottle requires a phased approach. Attempting a wholesale rewrite in one go is risky and disruptive. Instead, use the strangler fig pattern: gradually replace parts of the old application with Bottle-powered equivalents while keeping the system operational throughout the transition.
Phase 1: Inventory the Existing Application
Begin by cataloging every endpoint, middleware, template, and utility function in your legacy codebase. Create a spreadsheet or structured document listing:
- All URL routes with their HTTP methods and purpose
- Authentication and authorization mechanisms
- Session management implementation
- Database access patterns and ORM usage
- Template rendering and view logic
- Custom middleware and request interceptors
- Configuration management approach
This inventory becomes your migration roadmap. Every item on the list must find a corresponding implementation in Bottle—either using Bottle's native features or through appropriate Python libraries.
Phase 2: Establish a Parallel Bottle Application
Create a new Bottle application that runs alongside the legacy system. During this phase, both applications coexist, potentially on different ports or behind a reverse proxy that routes traffic based on path prefixes.
# legacy_app.py — your existing framework, left untouched
# bottle_app.py — the new Bottle application growing alongside
from bottle import Bottle, run
migration_app = Bottle()
# Start migrating one endpoint at a time
@migration_app.route('/api/v2/health')
def health_check():
return {'status': 'healthy', 'version': '2.0.0'}
Configure Nginx or HAProxy to route /api/v2/ traffic to the Bottle application while everything else continues hitting the legacy server. This allows real-world testing without affecting users.
# Example Nginx configuration snippet
# location /api/v1/ → legacy_app on port 9000
# location /api/v2/ → bottle_app on port 8080
Phase 3: Migrate Endpoint by Endpoint
Work through your inventory methodically. For each legacy endpoint, write the equivalent Bottle handler, replicate the behavior, and then switch the traffic routing rule to point the original URL at the new handler. This incremental approach means you can verify correctness and performance for each endpoint before moving on.
Here is a concrete example comparing a legacy Django view with its Bottle equivalent:
# Legacy Django view (Django 1.11 style)
# urls.py: url(r'^items/$', views.item_list)
# views.py:
def item_list(request):
items = Item.objects.filter(active=True).order_by('-created_at')
return render(request, 'items/list.html', {'items': items})
# ----------------------------------------------------------------
# Bottle equivalent
# ----------------------------------------------------------------
from bottle import Bottle, template
import sqlite3
app = Bottle()
def get_db():
db = sqlite3.connect('database.db')
db.row_factory = sqlite3.Row
return db
@app.route('/items/')
def item_list():
db = get_db()
cursor = db.execute(
'SELECT * FROM items WHERE active = 1 ORDER BY created_at DESC'
)
items = cursor.fetchall()
db.close()
return template('items/list', items=items)
Phase 4: Replace Middleware and Cross-Cutting Concerns
Legacy frameworks often rely on middleware stacks for authentication, logging, compression, and CORS handling. Bottle provides a plugin system and the ability to wrap the WSGI application with standard WSGI middleware, giving you equivalent capabilities.
# Authentication as a Bottle plugin
from bottle import Bottle, request, response, HTTPError
def auth_plugin(func):
def wrapper(*args, **kwargs):
token = request.get_cookie('session')
if not token or not validate_session(token):
raise HTTPError(401, 'Authentication required')
request.user = get_user_from_session(token)
return func(*args, **kwargs)
return wrapper
app = Bottle()
@app.route('/dashboard')
@auth_plugin
def dashboard():
return {'user': request.user['username']}
# For WSGI-level middleware (compression, CORS), wrap at run time:
from bottle import run
# run(app, host='0.0.0.0', port=8080)
# Or use Gunicorn with middleware in the WSGI pipeline
Phase 5: Decommission Legacy Code
Once all traffic routes through Bottle handlers and no requests reach the legacy application, you can remove the old codebase, simplify deployment to a single application, and retire the routing proxy configuration. Perform a final audit to ensure no orphaned endpoints or background tasks remain tied to the legacy framework.
Practical Migration Examples
Example 1: Migrating a Simple CRUD API from web.py
web.py was a popular minimalist framework in the Python 2 era. Many applications still run on it. Below is a side-by-side migration of a typical CRUD endpoint for managing notes.
# --- Original web.py application ---
import web
urls = (
'/notes', 'NotesHandler',
'/notes/(\d+)', 'NoteHandler',
)
db = web.database(dbn='sqlite', db='notes.db')
class NotesHandler:
def GET(self):
notes = list(db.select('notes'))
web.header('Content-Type', 'application/json')
return web.dumps(notes)
def POST(self):
data = web.input()
db.insert('notes', title=data.title, content=data.content)
return web.dumps({'status': 'created'})
class NoteHandler:
def GET(self, id):
note = db.where('notes', id=id).first()
return web.dumps(note) if note else web.notfound()
app = web.application(urls, globals())
# ----------------------------------------------------------------
# --- Migrated Bottle application ---
# ----------------------------------------------------------------
from bottle import Bottle, request, response, HTTPResponse
import sqlite3
import json
app = Bottle()
def get_db():
db = sqlite3.connect('notes.db')
db.row_factory = sqlite3.Row
return db
@app.route('/notes', method=['GET'])
def list_notes():
db = get_db()
cursor = db.execute('SELECT * FROM notes ORDER BY id')
notes = [dict(row) for row in cursor.fetchall()]
db.close()
response.content_type = 'application/json'
return json.dumps(notes)
@app.route('/notes', method=['POST'])
def create_note():
data = request.json or request.forms
db = get_db()
db.execute(
'INSERT INTO notes (title, content) VALUES (?, ?)',
(data.get('title'), data.get('content'))
)
db.commit()
db.close()
response.status = 201
return json.dumps({'status': 'created'})
@app.route('/notes/', method=['GET'])
def get_note(id):
db = get_db()
cursor = db.execute('SELECT * FROM notes WHERE id = ?', (id,))
note = cursor.fetchone()
db.close()
if note:
return json.dumps(dict(note))
return HTTPResponse(status=404, body=json.dumps({'error': 'Not found'}))
Example 2: Migrating Template Rendering from Jinja2 with Django Context
If your legacy application uses Jinja2 with a heavy context processor layer, you can replicate the same rendering pipeline in Bottle with far less machinery.
# --- Bottle with Jinja2 adapter ---
from bottle import Bottle, template
import jinja2
# Configure Jinja2 loader
jinja_env = jinja2.Environment(
loader=jinja2.FileSystemLoader('templates'),
autoescape=True
)
# Register Jinja2 with Bottle's template system
from bottle import jinja2_template as bottle_jinja2
# Bottle auto-detects Jinja2 if installed; just call template()
app = Bottle()
def common_context():
"""Replaces Django context processors with a simple function."""
return {
'site_name': 'My Migrated App',
'current_year': 2025,
'user': getattr(request, 'user', None)
}
@app.route('/profile')
def profile():
ctx = common_context()
ctx.update({'page_title': 'User Profile'})
return template('profile.html', **ctx)
# In your templates/ directory, Jinja2 templates work unchanged.
Example 3: Handling File Uploads and Form Data
Bottle provides straightforward access to file uploads and form data through the request object, replacing the often complex multipart handling of older frameworks.
from bottle import Bottle, request, static_file
import os
import uuid
app = Bottle()
UPLOAD_DIR = './uploads'
@app.route('/upload', method=['POST'])
def handle_upload():
# Access uploaded file data
upload = request.files.get('file')
if not upload:
return {'error': 'No file provided'}
# Generate a safe filename
filename = f"{uuid.uuid4().hex}_{upload.filename}"
save_path = os.path.join(UPLOAD_DIR, filename)
# Save the file
upload.save(save_path)
# Access form fields alongside the file
description = request.forms.get('description', '')
return {
'filename': filename,
'description': description,
'size': os.path.getsize(save_path)
}
Database Integration Patterns
Bottle does not ship with an ORM, which is actually an advantage during migration. You can retain your existing database layer—whether it is SQLAlchemy, raw SQL, or a custom data access library—without fighting framework assumptions about how persistence should work.
Option A: Keep Your Existing ORM
If your legacy application uses SQLAlchemy or Django ORM (through careful extraction), you can continue using it directly in Bottle handlers. The framework imposes no restrictions on database access patterns.
from bottle import Bottle
from sqlalchemy import create_engine, Column, Integer, String
from sqlalchemy.orm import sessionmaker
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
engine = create_engine('sqlite:///app.db')
Session = sessionmaker(bind=engine)
class User(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True)
name = Column(String)
app = Bottle()
@app.route('/users')
def users_list():
session = Session()
users = session.query(User).all()
session.close()
return {'users': [u.name for u in users]}
Option B: Adopt a Lightweight Data Layer
For smaller applications, migrating away from a heavy ORM can reduce code complexity. Bottle works beautifully with raw SQL via the sqlite3 module or lightweight query builders.
import sqlite3
def query_all(query, params=()):
"""Simple helper for read operations."""
conn = sqlite3.connect('app.db')
conn.row_factory = sqlite3.Row
cursor = conn.execute(query, params)
results = [dict(row) for row in cursor.fetchall()]
conn.close()
return results
def execute(query, params=()):
"""Simple helper for write operations."""
conn = sqlite3.connect('app.db')
conn.execute(query, params)
conn.commit()
conn.close()
@app.route('/tasks')
def task_list():
tasks = query_all('SELECT id, title, done FROM tasks ORDER BY id')
return {'tasks': tasks}
@app.route('/tasks', method=['POST'])
def task_create():
data = request.json
execute(
'INSERT INTO tasks (title, done) VALUES (?, ?)',
(data['title'], False)
)
return {'status': 'created'}
Configuration Management
Legacy frameworks often have elaborate configuration systems with multiple files, environment-specific overrides, and complex loading hierarchies. Bottle applications benefit from simpler configuration strategies. Use a combination of environment variables, a single configuration module, or a straightforward JSON/YAML file loaded at startup.
# config.py — a clean, framework-agnostic configuration module
import os
import json
class AppConfig:
def __init__(self, env='development'):
base_config = {
'debug': True,
'db_path': 'data/app.db',
'secret_key': os.environ.get('SECRET_KEY', 'change-me'),
'session_timeout': 3600,
}
# Load environment-specific overrides
env_file = f'config.{env}.json'
try:
with open(env_file) as f:
env_overrides = json.load(f)
base_config.update(env_overrides)
except FileNotFoundError:
pass
for key, value in base_config.items():
setattr(self, key, value)
config = AppConfig(os.environ.get('APP_ENV', 'production'))
Injecting Configuration into Handlers
Use Bottle's plugin system or a simple dependency injection pattern to make configuration available to route handlers.
def config_plugin(config_obj):
def decorator(func):
def wrapper(*args, **kwargs):
# Inject config as a keyword argument
return func(config=config_obj, *args, **kwargs)
return wrapper
return decorator
@app.route('/settings')
@config_plugin(config)
def show_settings(config):
return {'debug_mode': config.debug, 'db': config.db_path}
Testing Your Migrated Application
One of Bottle's strengths is testability. The framework provides a Bottle test client that allows you to simulate HTTP requests without starting an actual server. This is invaluable during migration to verify that each endpoint behaves identically to its legacy counterpart.
import unittest
from bottle import Bottle
app = Bottle()
@app.route('/api/echo', method=['POST'])
def echo():
return {'received': request.json}
class TestEchoEndpoint(unittest.TestCase):
def setUp(self):
# Bottle provides a test client
self.client = app.test()
def test_echo_returns_json(self):
response = self.client.post(
'/api/echo',
json={'message': 'hello'}
)
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.json,
{'received': {'message': 'hello'}}
)
def test_echo_rejects_get(self):
response = self.client.get('/api/echo')
self.assertEqual(response.status_code, 405)
if __name__ == '__main__':
unittest.main()
For integration tests against a live database, use an in-memory SQLite database or a dedicated test database instance, and run the Bottle application with the test configuration loaded.
Deployment Considerations
Deploying a Bottle application is dramatically simpler than deploying most legacy frameworks. Since Bottle is WSGI-compliant, you can use any standard Python application server. The recommended approach for production is Gunicorn behind Nginx.
# app.py — your Bottle application
from bottle import Bottle
app = Bottle()
# ... route definitions ...
# For Gunicorn, expose the WSGI app object:
# gunicorn app:app -w 4 -b 0.0.0.0:8000
A typical production deployment pipeline looks like:
- Development — use Bottle's built-in server with
debug=Trueand auto-reloading - Staging — deploy with Gunicorn on a test server, validate with your test suite
- Production — Gunicorn behind Nginx, with
reload=Falseand appropriate worker counts
Because Bottle applications have minimal startup overhead, they are ideal for containerized deployments. A Dockerfile for a Bottle application can be extremely compact:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["gunicorn", "app:app", "-w", "4", "-b", "0.0.0.0:8000"]
Best Practices for a Successful Migration
1. Preserve URL Compatibility
Keep legacy URLs working exactly as they did before. Bottle's routing supports dynamic segments and regex constraints, so you can replicate even complex URL patterns. If URL changes are unavoidable, implement permanent redirects from old paths to new ones using Bottle's redirect() function.
@app.route('/old/path/')
def legacy_redirect(id):
redirect(f'/new/path/{id}', 301)
2. Log Everything During Transition
Add detailed logging to both the legacy and Bottle applications during the migration period. This helps catch discrepancies in behavior, timing, or error handling between the two implementations.
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger('migration')
@app.route('/api/data')
def data_endpoint():
logger.info(f"Bottle handler called: {request.method} {request.url}")
# handler logic...
3. Match Error Responses Exactly
Clients may depend on specific error response formats. Ensure your Bottle error handlers return the same HTTP status codes, response bodies, and headers as the legacy application. Test this thoroughly.
4. Handle Sessions Thoughtfully
If the legacy application uses server-side sessions, you may need to extract the session store and connect it to Bottle. For cookie-based sessions, ensure encryption and signing keys remain consistent so users do not lose their sessions during the cutover.
5. Monitor Performance
Bottle's lightweight nature often improves response times, but verify this with actual measurements. Use tools like wrk or locust to benchmark the migrated endpoints against the legacy versions before fully switching traffic.
6. Document the New Architecture
One of the biggest wins in migration is the opportunity to create clean, accurate documentation. Record every endpoint, its expected inputs and outputs, authentication requirements, and rate limits. Bottle's simplicity makes this documentation easier to write and maintain.
7. Plan for Rollback
Keep the legacy application deployable and runnable until you are absolutely confident in the Bottle replacement. Maintain the ability to revert traffic routing to the legacy application with a single configuration change. Only fully decommission the old system after a successful monitoring period.
Common Pitfalls and How to Avoid Them
- Assuming Bottle does too little — Bottle is intentionally minimal, but you can add exactly the libraries you need. Don't mistake minimalism for inadequacy; it is flexibility.
- Overcomplicating the Bottle application — Resist the urge to build a framework on top of Bottle. Keep handlers simple, extract shared logic into plain Python functions, and avoid introducing complex plugin hierarchies unless absolutely necessary.
- Ignoring WSGI middleware compatibility — Bottle works with any WSGI middleware. If you need compression, use
wsgi-gzip. If you need CORS, use a WSGI-level CORS handler. Don't reinvent these in application code. - Forgetting about asynchronous boundaries — Bottle is synchronous by design. If your application has long-running I/O operations, handle them with a task queue (Celery, RQ) rather than blocking the WSGI worker.
- Skipping the inventory phase — Migrating without a complete endpoint inventory leads to forgotten functionality, broken API contracts, and frustrated users. Invest the time upfront.
Conclusion
Migrating from a legacy framework to Bottle is a strategic investment in your application's future. The process strips away unnecessary complexity, reduces dependency risk, and produces a codebase that is easier to understand, test, and deploy. By following a phased migration approach—inventorying the existing system, running Bottle in parallel, migrating endpoints incrementally, and only decommissioning when fully validated—you can achieve a smooth transition with minimal disruption. Bottle's zero-dependency, single-module design ensures that once migrated, your application will remain portable, maintainable, and resilient against the dependency rot that eventually plagues every framework-heavy project. The end result is not just a successful migration, but a cleaner, faster, and more sustainable web application.