← Back to DevBytes

Migrating from Legacy Frameworks to Bottle

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:

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

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:

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:

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

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles