← Back to DevBytes

Migrating from Legacy Frameworks to CherryPy

Introduction: What Is CherryPy and Why Migrate?

CherryPy is a minimalist, object-oriented HTTP framework for Python that allows developers to build web applications in a manner strikingly similar to writing regular Python programs. Unlike monolithic frameworks that impose directory structures, ORM layers, and templating engines, CherryPy stays out of your way. It provides a pure HTTP/WSGI interface where each handler method maps directly to a URL segment, making the mental model refreshingly simple.

Migrating from legacy frameworks—such as older versions of Django, Pyramid, web.py, Flask monoliths, or even proprietary in-house CGI-based systems—to CherryPy offers several compelling advantages:

Assessing Your Legacy Application Before Migration

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Before writing a single line of CherryPy code, you need to audit your existing application. Legacy frameworks often entangle HTTP handling, business logic, and data access in ways that make extraction challenging. Here is a systematic approach:

1. Separate Business Logic from HTTP Handling

The most critical step is to extract pure business logic into standalone Python modules that know nothing about HTTP requests, responses, or framework-specific constructs. For example, if you have a Flask route that validates input, queries a database, and returns JSON, pull the validation and database operations into a separate service layer.

Before (Flask-legacy hybrid):

# legacy_app/routes.py
from flask import request, jsonify
from legacy_app.models import User

@app.route('/users/')
def get_user(user_id):
    user = User.query.get(user_id)
    if not user:
        return jsonify({'error': 'Not found'}), 404
    return jsonify({'id': user.id, 'name': user.name})

After extraction (framework-agnostic service):

# services/user_service.py
class UserService:
    def get_user_by_id(self, user_id):
        # Pure business logic, no HTTP awareness
        user = User.query.get(user_id)
        if not user:
            return None
        return {'id': user.id, 'name': user.name}

2. Inventory All Routes and Middleware

Create a spreadsheet listing every URL route, HTTP method, authentication requirement, and middleware function in your legacy application. CherryPy handles routing through object trees, so this inventory will become your class hierarchy blueprint.

3. Identify Framework-Specific Features That Need Replacement

Many legacy frameworks provide built-in session management, CSRF protection, template rendering, and ORM integration. CherryPy intentionally does not include these, preferring composable third-party libraries. Identify which components you will need to replace or reimplement.

Step-by-Step Migration Process

Step 1: Install CherryPy and Create a Minimal Application Shell

Install CherryPy in a virtual environment and create a basic application that serves a single endpoint. This gives you immediate feedback that your environment is working correctly.

pip install cherrypy
# app.py
import cherrypy

class Root:
    @cherrypy.expose
    def index(self):
        return "Migration is underway!"

if __name__ == '__main__':
    cherrypy.quickstart(Root())

Run this with python app.py and visit http://localhost:8080. You should see the welcome message. This confirms that CherryPy's built-in server is operational.

Step 2: Map Legacy Routes to CherryPy's Object Hierarchy

CherryPy dispatches requests by traversing a tree of objects. Each object corresponds to a URL segment, and exposed methods handle terminal segments. For example, the URL /users/profile/edit would map to root.users.profile.edit().

Here is how you translate a typical RESTful resource from a legacy framework:

# Legacy Flask routes:
# GET    /api/users          -> list_users()
# GET    /api/users/     -> get_user(id)
# POST   /api/users          -> create_user()
# PUT    /api/users/     -> update_user(id)
# DELETE /api/users/     -> delete_user(id)

The equivalent CherryPy structure:

# cherrypy_app/resources/users.py
import cherrypy
from services.user_service import UserService

class UsersResource:
    def __init__(self):
        self.service = UserService()

    @cherrypy.expose
    def index(self):
        """Handles GET /api/users"""
        if cherrypy.request.method == 'GET':
            users = self.service.list_users()
            return json.dumps(users)
        if cherrypy.request.method == 'POST':
            data = cherrypy.request.json
            new_user = self.service.create_user(data)
            cherrypy.response.status = 201
            return json.dumps(new_user)
        cherrypy.response.status = 405
        return json.dumps({'error': 'Method not allowed'})

    @cherrypy.expose
    def default(self, user_id):
        """Handles /api/users/{user_id} for various methods"""
        if cherrypy.request.method == 'GET':
            user = self.service.get_user_by_id(int(user_id))
            if user is None:
                cherrypy.response.status = 404
                return json.dumps({'error': 'Not found'})
            return json.dumps(user)
        if cherrypy.request.method == 'PUT':
            data = cherrypy.request.json
            updated = self.service.update_user(int(user_id), data)
            return json.dumps(updated)
        if cherrypy.request.method == 'DELETE':
            self.service.delete_user(int(user_id))
            cherrypy.response.status = 204
            return ''
        cherrypy.response.status = 405
        return json.dumps({'error': 'Method not allowed'})

Notice the default method. In CherryPy, when a URL segment does not match a named method, the framework calls default if it exists, passing the segment as a positional argument. This is the canonical way to handle path parameters.

Step 3: Wire Up the Object Tree

Your root application object assembles the tree:

# cherrypy_app/main.py
import cherrypy
from cherrypy_app.resources.users import UsersResource

class ApiRoot:
    @cherrypy.expose
    def index(self):
        return json.dumps({'version': '1.0', 'status': 'migrated'})

# Mount sub-resources as attributes
class Root:
    def __init__(self):
        self.api = ApiRoot()
        self.api.users = UsersResource()

if __name__ == '__main__':
    config = {
        'global': {
            'server.socket_host': '0.0.0.0',
            'server.socket_port': 8080,
        }
    }
    cherrypy.quickstart(Root(), '/', config)

Now visiting /api/users and /api/users/42 will route to the appropriate handlers.

Step 4: Migrate Middleware to CherryPy Tools and Hooks

Legacy frameworks often use decorator-based middleware or before/after request filters. CherryPy provides a powerful hooks system and the concept of "Tools" for cross-cutting concerns like authentication, logging, and rate limiting.

Example: Migrating an authentication decorator

Legacy code might look like this:

# Legacy auth decorator
@require_auth
def protected_endpoint():
    pass

In CherryPy, you create a Tool that runs before the handler:

# cherrypy_app/tools/auth.py
import cherrypy
from services.auth_service import AuthService

def check_auth():
    """CherryPy Tool that validates authentication on every request."""
    auth_header = cherrypy.request.headers.get('Authorization')
    if not auth_header:
        cherrypy.response.status = 401
        cherrypy.response.body = json.dumps({'error': 'Missing authorization'})
        cherrypy.request.handler = None  # Short-circuit: don't call the page handler
        return
    token = auth_header.replace('Bearer ', '', 1)
    user = AuthService().validate_token(token)
    if not user:
        cherrypy.response.status = 403
        cherrypy.response.body = json.dumps({'error': 'Invalid token'})
        cherrypy.request.handler = None
        return
    # Attach user to request for downstream handlers
    cherrypy.request.authenticated_user = user

# Register the tool
cherrypy.tools.auth = cherrypy.Tool('before_handler', check_auth)

Now you can apply it to any handler:

class SecureResource:
    _cp_config = {
        'tools.auth.on': True
    }

    @cherrypy.expose
    def index(self):
        user = cherrypy.request.authenticated_user
        return json.dumps({'user': user['name']})

The _cp_config dictionary on a class or method tells CherryPy to enable specific tools. You can also apply tools globally in the server configuration.

Step 5: Handle Sessions and State Management

If your legacy framework uses server-side sessions, you can implement session management in CherryPy using a Tool or by enabling CherryPy's built-in session support. CherryPy ships with a session library that supports file-based, memory-based, and memcached backends.

# Enable CherryPy sessions
config = {
    'global': {
        'server.socket_port': 8080,
        'tools.sessions.on': True,
        'tools.sessions.storage_type': 'file',
        'tools.sessions.storage_path': 'sessions',
        'tools.sessions.timeout': 60,  # minutes
    }
}

class ShoppingCart:
    @cherrypy.expose
    def add(self, item_id):
        cart = cherrypy.session.get('cart', [])
        cart.append(item_id)
        cherrypy.session['cart'] = cart
        return json.dumps({'cart': cart})

    @cherrypy.expose
    def view(self):
        return json.dumps({'cart': cherrypy.session.get('cart', [])})

For more complex state needs, consider migrating to signed cookies or a dedicated session service backed by Redis, accessed through your service layer.

Step 6: Migrate Template Rendering

CherryPy does not mandate a templating engine. If your legacy app uses Jinja2 (common in Flask and Django), you can continue using it seamlessly:

# cherrypy_app/renderer.py
from jinja2 import Environment, FileSystemLoader
import cherrypy

class TemplateTool:
    def __init__(self, template_dir='templates'):
        self.env = Environment(loader=FileSystemLoader(template_dir))

    def render(self, template_name, context=None):
        context = context or {}
        template = self.env.get_template(template_name)
        return template.render(**context)

# Instantiate globally or per-request
renderer = TemplateTool()

class Pages:
    @cherrypy.expose
    def about(self):
        return renderer.render('about.html', {'title': 'About Us'})

You can also wrap rendering in a CherryPy Tool that post-processes handler output, automatically rendering a template if the handler returns a dictionary.

Step 7: Migrate Static File Serving

CherryPy has built-in static file serving that can replace the static directory mounts found in other frameworks:

config = {
    'global': {
        'server.socket_port': 8080,
    },
    '/static': {
        'tools.staticdir.on': True,
        'tools.staticdir.root': '/absolute/path/to/project',
        'tools.staticdir.dir': 'public',
    }
}

This serves files from /absolute/path/to/project/public at the URL prefix /static. No additional code is required.

Step 8: Replace Legacy Error Handling

Legacy frameworks often have global error handlers. CherryPy offers a clean hook-based approach:

# cherrypy_app/errors.py
import cherrypy
import json

def json_error_handler():
    """Convert unhandled exceptions into JSON error responses."""
    cherrypy.response.headers['Content-Type'] = 'application/json'
    status = cherrypy.response.status
    tb = cherrypy._cperror.format_exc()

    response = {
        'status': status,
        'error': str(cherrypy.request._exception),
    }
    # In production, hide traceback details
    if cherrypy.config.get('environment') == 'production':
        response.pop('traceback', None)
    else:
        response['traceback'] = tb

    cherrypy.response.body = json.dumps(response).encode('utf-8')

cherrypy.config.update({
    'request.error_response': json_error_handler,
    'environment': 'development',
})

This single hook intercepts all uncaught exceptions and formats them consistently, replacing scattered error handlers in legacy code.

Complete Migration Example: A Mini Web Application

Below is a complete, runnable CherryPy application that demonstrates all the concepts discussed—a simple task management API migrated from a hypothetical legacy Flask application:

# complete_migrated_app.py
import cherrypy
import json
import uuid
from datetime import datetime

# ---------- Service Layer (framework-agnostic) ----------
class TaskService:
    def __init__(self):
        self.tasks = {}  # In-memory store for simplicity

    def list_tasks(self, status_filter=None):
        tasks = list(self.tasks.values())
        if status_filter:
            tasks = [t for t in tasks if t['status'] == status_filter]
        return sorted(tasks, key=lambda t: t['created_at'], reverse=True)

    def get_task(self, task_id):
        return self.tasks.get(task_id)

    def create_task(self, title, description=''):
        task_id = str(uuid.uuid4())[:8]
        task = {
            'id': task_id,
            'title': title,
            'description': description,
            'status': 'pending',
            'created_at': datetime.utcnow().isoformat(),
            'updated_at': datetime.utcnow().isoformat(),
        }
        self.tasks[task_id] = task
        return task

    def update_task(self, task_id, updates):
        if task_id not in self.tasks:
            return None
        allowed_fields = {'title', 'description', 'status'}
        for key, value in updates.items():
            if key in allowed_fields:
                self.tasks[task_id][key] = value
        self.tasks[task_id]['updated_at'] = datetime.utcnow().isoformat()
        return self.tasks[task_id]

    def delete_task(self, task_id):
        return self.tasks.pop(task_id, None)

# ---------- Auth Tool ----------
def check_api_key():
    api_key = cherrypy.request.headers.get('X-API-Key')
    if not api_key or api_key != 'secret-migration-key':
        cherrypy.response.status = 401
        cherrypy.response.body = json.dumps({'error': 'Invalid API key'}).encode()
        cherrypy.request.handler = None

cherrypy.tools.api_key = cherrypy.Tool('before_handler', check_api_key)

# ---------- Error Handler ----------
def global_error_handler():
    cherrypy.response.headers['Content-Type'] = 'application/json'
    body = {
        'status': cherrypy.response.status,
        'error': str(cherrypy.request._exception),
    }
    cherrypy.response.body = json.dumps(body).encode()

# ---------- Resource Classes ----------
class TasksResource:
    def __init__(self):
        self.service = TaskService()
        self._cp_config = {'tools.api_key.on': True}

    @cherrypy.expose
    def index(self, status=None):
        if cherrypy.request.method == 'GET':
            tasks = self.service.list_tasks(status_filter=status)
            return json.dumps({'tasks': tasks, 'count': len(tasks)})
        if cherrypy.request.method == 'POST':
            data = cherrypy.request.json
            task = self.service.create_task(
                title=data.get('title', 'Untitled'),
                description=data.get('description', '')
            )
            cherrypy.response.status = 201
            return json.dumps(task)
        cherrypy.response.status = 405
        return json.dumps({'error': 'Method not allowed'})

    @cherrypy.expose
    def default(self, task_id):
        task = self.service.get_task(task_id)
        if task is None:
            cherrypy.response.status = 404
            return json.dumps({'error': f'Task {task_id} not found'})

        if cherrypy.request.method == 'GET':
            return json.dumps(task)
        if cherrypy.request.method == 'PUT':
            updates = cherrypy.request.json
            updated = self.service.update_task(task_id, updates)
            return json.dumps(updated)
        if cherrypy.request.method == 'DELETE':
            self.service.delete_task(task_id)
            cherrypy.response.status = 204
            return ''
        cherrypy.response.status = 405
        return json.dumps({'error': 'Method not allowed'})

class HealthResource:
    @cherrypy.expose
    def index(self):
        return json.dumps({
            'status': 'healthy',
            'framework': 'CherryPy',
            'migrated_at': datetime.utcnow().isoformat(),
        })

class Root:
    def __init__(self):
        self.api = self.ApiRoot()

    class ApiRoot:
        @cherrypy.expose
        def index(self):
            return json.dumps({'endpoints': ['/api/tasks', '/api/health']})

    def __getattr__(self, name):
        if name == 'api':
            return self.ApiRoot()
        raise AttributeError(name)

# Override __getattr__ to lazily mount resources
Root.api.tasks = TasksResource()
Root.api.health = HealthResource()

# ---------- Configuration and Startup ----------
if __name__ == '__main__':
    cherrypy.config.update({
        'global': {
            'server.socket_host': '127.0.0.1',
            'server.socket_port': 8080,
            'request.error_response': global_error_handler,
        }
    })
    cherrypy.quickstart(Root())

This complete application demonstrates the full migration pattern: service layer extraction, CherryPy resource classes, tool-based authentication, global error handling, and a clean object tree structure. Run it with python complete_migrated_app.py and test endpoints like GET /api/tasks and POST /api/tasks with the header X-API-Key: secret-migration-key.

Best Practices for CherryPy Migration

Keep Services Framework-Agnostic

The service layer should never import cherrypy. This ensures your business logic remains testable and portable. If a service needs request data, pass it as arguments from the handler method. This separation pays enormous dividends in testability and future migrations.

# Good: handler gathers data, passes to service
@cherrypy.expose
def search(self, query):
    results = SearchService().execute(query)
    return json.dumps(results)

# Bad: service directly accesses CherryPy globals
class SearchService:
    def execute(self):
        query = cherrypy.request.params.get('query')  # Avoid this!

Use _cp_config for Declarative Tool Configuration

CherryPy's _cp_config dictionary on classes and methods allows declarative configuration of tools, content types, and other settings. This is much cleaner than imperative setup code scattered across your application.

class SecureReports:
    _cp_config = {
        'tools.auth.on': True,
        'tools.gzip.on': True,
        'tools.gzip.mime_types': ['text/html', 'application/json'],
        'response.headers.content-type': 'application/json',
    }

    @cherrypy.expose
    def monthly(self):
        # Auth and gzip are already handled
        return json.dumps({'report': 'monthly_data'})

Leverage CherryPy's Built-in HTTP Server for Simplicity

CherryPy's threaded server is production-tested and eliminates the need for Gunicorn or uWSGI in many deployments. For production, place it behind Nginx or HAProxy as a reverse proxy. This reduces operational complexity compared to legacy setups with separate application servers.

Write Comprehensive Tests During Migration

Migration is the ideal time to build a thorough test suite. CherryPy applications are exceptionally testable because you can instantiate your root object and call handler methods directly without starting the server:

# test_tasks.py
import json
from complete_migrated_app import Root, TaskService

def test_get_tasks():
    root = Root()
    # Call the handler directly
    response = root.api.tasks.index()
    data = json.loads(response)
    assert 'tasks' in data
    assert data['count'] == 0

def test_create_task():
    root = Root()
    # Simulate a POST by setting request method
    cherrypy.request.method = 'POST'
    cherrypy.request.json = {'title': 'Test Task'}
    response = root.api.tasks.index()
    assert 'id' in json.loads(response)

Migrate Incrementally, Not All at Once

For large applications, mount both CherryPy and the legacy framework behind a reverse proxy during the transition. Route specific URL prefixes to CherryPy while the legacy system handles the rest. CherryPy's WSGI compatibility makes it trivial to run alongside other WSGI applications.

Document Your New CherryPy Architecture

The object tree is your URL map. Maintain a living document showing the class hierarchy and which tools apply at each level. This replaces sprawling route files and middleware registrations with a single, navigable structure.

Common Pitfalls and How to Avoid Them

Pitfall 1: Overusing the Global Request Object

CherryPy provides cherrypy.request and cherrypy.response as thread-safe globals. While convenient, overusing them in service-layer code defeats the purpose of separation. Restrict their use to handler methods and Tools.

Pitfall 2: Forgetting to Set Content Types

Unlike some frameworks that auto-detect JSON responses, CherryPy returns raw strings. Always set cherrypy.response.headers['Content-Type'] or use a Tool to do so automatically when returning JSON.

Pitfall 3: Ignoring CherryPy's Threading Model

CherryPy's server is multithreaded by default. Ensure your service layer is thread-safe. If you have mutable shared state (like the in-memory task store in the example), protect it with locks or migrate to a thread-safe datastore like Redis or an RDBMS with connection pooling.

Pitfall 4: Misunderstanding default Methods

The default method catches all unmatched URL segments. If you have nested resources, make sure default passes remaining segments correctly or raises cherrypy.NotFound() to trigger a 404.

Performance Considerations After Migration

One pleasant surprise after migrating to CherryPy is often improved throughput. CherryPy's HTTP server is written in pure Python but optimized for typical web workloads. To maximize performance:

cherrypy.config.update({
    'server.thread_pool': 20,
    'tools.gzip.on': True,
    'tools.etags.on': True,
    'tools.etags.autotags': True,
})

Conclusion

Migrating from a legacy framework to CherryPy is fundamentally an exercise in simplification. By extracting business logic into framework-agnostic services, mapping URL structures to CherryPy's object hierarchy, and replacing opaque middleware with transparent Tools and hooks, you end up with a codebase that is easier to understand, test, and maintain. The migration path laid out in this tutorial—audit, extract, map, configure, and test—provides a repeatable process that minimizes risk while maximizing the benefits of CherryPy's minimalist philosophy. The framework's long history of stability and its commitment to backward compatibility mean your migrated application will remain viable for years to come, free from the upgrade treadmill that plagues many larger frameworks.

🚀 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