← Back to DevBytes

Migrating from Legacy Frameworks to Flask

What Is Flask and Why Migrate from Legacy Frameworks?

Flask is a lightweight WSGI web application framework for Python. It's classified as a microframework because it keeps the core simple but extensible, allowing developers to add only the features they truly need. Legacy frameworks such as Django (older versions), Pyramid, web2py, Bottle, or even PHP-based frameworks like CodeIgniter often carry years of accumulated complexity, deprecated dependencies, and monolithic structures that slow down modern development workflows.

Migration from legacy frameworks to Flask matters because many organizations are saddled with codebases that are difficult to maintain, hard to scale, and incompatible with modern tooling. Flask offers a minimal footprint, excellent compatibility with contemporary Python ecosystems (asyncio, type hints, modern ORMs), and the flexibility to incrementally rewrite applications rather than requiring a big-bang rewrite. It also integrates seamlessly with Docker, Kubernetes, and microservice architectures — something many older frameworks struggle to support cleanly.

Understanding the Migration Landscape

🚀 Deploy your AI agent in 10 minutes

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

Try it free →

Before writing any code, you need to assess the architecture of your legacy application. Most older frameworks follow a Model-View-Controller (MVC) pattern, use a built-in ORM, and ship with their own templating engine, session management, and routing system. Flask, by contrast, provides routing, request/response handling, and templating (via Jinja2) out of the box, but leaves database abstraction, form validation, and authentication to third-party extensions. This shift in philosophy — from "batteries included" to "choose your own components" — is both the greatest opportunity and the primary challenge of migration.

Common Legacy Frameworks and Their Flask Equivalents

Step-by-Step Migration Strategy

A successful migration follows an incremental approach. You should never attempt to rewrite an entire production application in one go. Instead, use the Strangler Fig pattern: gradually replace pieces of the legacy system with Flask-powered endpoints while keeping the old system running in parallel.

Step 1: Inventory Your Legacy Routes and Business Logic

Begin by cataloging every route, controller, model, and middleware function in your legacy application. Create a spreadsheet or YAML document that maps old URL patterns to their handlers. For a Django application, you might extract this from urls.py files. For a CodeIgniter app, you'd scan the routes.php configuration and controller directory.

# Example: Legacy Django URL inventory (urls.py excerpt)
# /api/v1/users/          → users.views.UserListView
# /api/v1/users/<id>/     → users.views.UserDetailView
# /api/v1/products/       → products.views.ProductListView
# /checkout/              → cart.views.CheckoutView
# /admin/dashboard/       → admin.views.DashboardView

Step 2: Set Up a Parallel Flask Application

Create a new Flask project alongside your existing codebase. You can run both applications on different ports during the transition, or use a reverse proxy (Nginx, Traefik) to route traffic between them based on URL patterns. Start with a minimal Flask app structure:

# app.py — Minimal Flask entry point
from flask import Flask, jsonify, request
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
import config

app = Flask(__name__)
app.config.from_object(config.DevelopmentConfig)

db = SQLAlchemy(app)
migrate = Migrate(app, db)

# Health check endpoint (useful during migration)
@app.route('/health')
def health_check():
    return jsonify({'status': 'ok', 'framework': 'flask'})

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5001)

Step 3: Map Legacy Routes to Flask Blueprints

Flask blueprints allow you to organize routes into modular groups, mirroring the module structure of your legacy framework. Create one blueprint per legacy Django "app" or CodeIgniter "controller group." This keeps the migration organized and lets you migrate one module at a time.

# routes/users.py — Blueprint for user-related routes
from flask import Blueprint, jsonify, request
from models.user import User
from db import db

users_bp = Blueprint('users', __name__, url_prefix='/api/v1/users')

@users_bp.route('/', methods=['GET'])
def list_users():
    page = request.args.get('page', 1, type=int)
    per_page = request.args.get('per_page', 20, type=int)
    users = User.query.paginate(page=page, per_page=per_page)
    return jsonify({
        'data': [u.to_dict() for u in users.items],
        'total': users.total,
        'page': page
    })

@users_bp.route('/', methods=['GET'])
def get_user(user_id):
    user = User.query.get_or_404(user_id)
    return jsonify({'data': user.to_dict()})

@users_bp.route('/', methods=['PUT'])
def update_user(user_id):
    user = User.query.get_or_404(user_id)
    data = request.get_json()
    user.name = data.get('name', user.name)
    user.email = data.get('email', user.email)
    db.session.commit()
    return jsonify({'data': user.to_dict()})

Register all blueprints in your main application factory:

# app_factory.py
from flask import Flask
from routes.users import users_bp
from routes.products import products_bp
from routes.orders import orders_bp

def create_app():
    app = Flask(__name__)
    app.config.from_object('config.ProductionConfig')
    
    app.register_blueprint(users_bp)
    app.register_blueprint(products_bp)
    app.register_blueprint(orders_bp)
    
    return app

Step 4: Reimplement Models with SQLAlchemy

If your legacy framework uses Django ORM, you'll need to translate model definitions to SQLAlchemy. The key differences are in field declarations, relationship definitions, and query syntax. Here's a side-by-side comparison to guide your translation:

# Legacy Django model (models.py)
# class Product(models.Model):
#     name = models.CharField(max_length=255)
#     price = models.DecimalField(max_digits=10, decimal_places=2)
#     category = models.ForeignKey('Category', on_delete=models.CASCADE)
#     created_at = models.DateTimeField(auto_now_add=True)
#
#     def __str__(self):
#         return self.name

# Flask + SQLAlchemy equivalent
from datetime import datetime
from db import db

class Product(db.Model):
    __tablename__ = 'products'
    
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(255), nullable=False)
    price = db.Column(db.Numeric(10, 2), nullable=False)
    category_id = db.Column(db.Integer, db.ForeignKey('categories.id'))
    created_at = db.Column(db.DateTime, default=datetime.utcnow)
    
    # Relationship
    category = db.relationship('Category', backref=db.backref('products', lazy='dynamic'))
    
    def to_dict(self):
        return {
            'id': self.id,
            'name': self.name,
            'price': str(self.price),
            'category_id': self.category_id,
            'created_at': self.created_at.isoformat()
        }

Step 5: Handle Authentication and Sessions

Legacy frameworks often bundle authentication tightly with their session mechanism. In Flask, you'll typically use Flask-Login for session-based auth or Flask-JWT-Extended for token-based auth. Migrate your user verification logic carefully, preserving password hashing algorithms (bcrypt, PBKDF2) as-is to avoid forcing all users to reset passwords.

# auth.py — Flask-Login setup for session-based authentication
from flask import Blueprint, request, jsonify, session
from flask_login import LoginManager, login_user, logout_user, login_required
from models.user import User
from werkzeug.security import check_password_hash

auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
login_manager = LoginManager()

@login_manager.user_loader
def load_user(user_id):
    return User.query.get(int(user_id))

@auth_bp.route('/login', methods=['POST'])
def login():
    data = request.get_json()
    user = User.query.filter_by(email=data.get('email')).first()
    
    if user and check_password_hash(user.password_hash, data.get('password')):
        login_user(user, remember=data.get('remember', False))
        return jsonify({'message': 'Login successful', 'user': user.to_dict()})
    
    return jsonify({'error': 'Invalid credentials'}), 401

@auth_bp.route('/logout', methods=['POST'])
@login_required
def logout():
    logout_user()
    return jsonify({'message': 'Logged out successfully'})

Step 6: Migrate Middleware and Cross-Cutting Concerns

Legacy middleware — such as request logging, CORS handling, rate limiting, or custom authentication checks — translates to Flask's before_request and after_request hooks, or to dedicated extensions like Flask-CORS and Flask-Limiter.

# middleware.py — Replicating legacy middleware behavior
from flask import request, g, jsonify
from datetime import datetime
import logging

logger = logging.getLogger(__name__)

def register_middleware(app):
    @app.before_request
    def log_request():
        g.request_start_time = datetime.utcnow()
        logger.info(f"Incoming {request.method} {request.path}")
    
    @app.after_request
    def log_response(response):
        elapsed = (datetime.utcnow() - g.request_start_time).total_seconds()
        logger.info(f"Response {response.status_code} in {elapsed:.3f}s")
        response.headers['X-Response-Time'] = str(elapsed)
        return response
    
    @app.before_request
    def check_maintenance_mode():
        if app.config.get('MAINTENANCE_MODE'):
            if request.path != '/health' and request.remote_addr not in app.config.get('ADMIN_IPS', []):
                return jsonify({'error': 'Service under maintenance'}), 503

Step 7: Set Up a Reverse Proxy for Gradual Cutover

During the transition period, run both the legacy application and the new Flask application simultaneously. Use Nginx or HAProxy to route traffic based on URL prefixes. This allows you to migrate routes incrementally and roll back individual endpoints if issues arise.

# nginx.conf — Gradual traffic routing between legacy and Flask
# server {
#     listen 80;
#     server_name example.com;
#
#     # Routes already migrated to Flask
#     location /api/v1/users {
#         proxy_pass http://flask-app:5001;
#     }
#     location /api/v1/products {
#         proxy_pass http://flask-app:5001;
#     }
#     location /health {
#         proxy_pass http://flask-app:5001;
#     }
#
#     # Everything else still goes to legacy
#     location / {
#         proxy_pass http://legacy-app:8000;
#     }
# }

Step 8: Migrate Templates and Views

If your legacy application uses server-side rendering (Django Templates, Smarty, Blade), you'll migrate these to Jinja2, Flask's default templating engine. The syntax is similar across most engines, but pay attention to escaping rules, block inheritance, and custom template tags that may require porting to Jinja2 extensions.

{# templates/users/profile.html — Jinja2 equivalent of legacy template #}
{% extends "base.html" %}

{% block title %}{{ user.name }}'s Profile{% endblock %}

{% block content %}

{{ user.name }}

{% if user.is_verified %} Verified {% else %} Pending Verification {% endif %}

Recent Orders

{% if orders %} {% else %}

No orders yet.

{% endif %}
{% endblock %}

Data Migration and Database Compatibility

One of the trickiest aspects of migration is handling the existing database. In most cases, you should keep the same database and schema initially, and gradually evolve it. Flask-SQLAlchemy can reflect existing tables, allowing you to work with the legacy schema without immediate migration. Use Alembic (via Flask-Migrate) for schema evolution once you're ready to make changes.

# Reflecting legacy database tables with SQLAlchemy
from db import db

# Reflect an existing table without altering it
class LegacyOrder(db.Model):
    __tablename__ = 'legacy_orders'
    __table_args__ = {'extend_existing': True}
    
    id = db.Column(db.Integer, primary_key=True)
    # Other columns will be reflected automatically
    # You only need to declare columns you'll reference explicitly
    
    def to_dict(self):
        return {
            'id': self.id,
            'status': getattr(self, 'status', None),
            'total': getattr(self, 'total', None)
        }

Testing During Migration

Testing is critical during migration. You need to verify that each migrated endpoint behaves identically to its legacy counterpart. Write contract tests that compare responses between the old and new systems for the same inputs. Use pytest with Flask's test client to build a comprehensive test suite.

# tests/test_users_contract.py — Contract testing during migration
import pytest
import requests
from app_factory import create_app

LEGACY_BASE_URL = "http://localhost:8000"
FLASK_BASE_URL = "http://localhost:5001"

@pytest.fixture
def flask_client():
    app = create_app()
    app.config['TESTING'] = True
    return app.test_client()

def test_user_list_endpoint_parity():
    """Verify Flask response matches legacy response structure."""
    # Call legacy endpoint
    legacy_response = requests.get(f"{LEGACY_BASE_URL}/api/v1/users?page=1&per_page=5")
    legacy_data = legacy_response.json()
    
    # Call Flask endpoint
    flask_response = requests.get(f"{FLASK_BASE_URL}/api/v1/users?page=1&per_page=5")
    flask_data = flask_response.json()
    
    # Assert structural parity
    assert 'data' in flask_data
    assert 'total' in flask_data
    assert len(flask_data['data']) == len(legacy_data['data'])
    assert flask_response.status_code == legacy_response.status_code

def test_user_detail_contract():
    """Test that user detail responses have matching shape."""
    # Fetch a known user ID from legacy
    legacy_resp = requests.get(f"{LEGACY_BASE_URL}/api/v1/users/42")
    flask_resp = requests.get(f"{FLASK_BASE_URL}/api/v1/users/42")
    
    assert legacy_resp.status_code == flask_resp.status_code
    # Both should return JSON with same top-level keys
    legacy_keys = set(legacy_resp.json().get('data', {}).keys())
    flask_keys = set(flask_resp.json().get('data', {}).keys())
    assert legacy_keys == flask_keys

Best Practices for Flask Migration

1. Use the Application Factory Pattern from Day One

Instead of a single global Flask instance, use the application factory pattern. This makes testing easier, supports multiple configurations, and prevents circular import issues that plague large Flask projects. Structure your project with a create_app() function that accepts configuration as a parameter.

2. Keep Business Logic Separate from Flask-Specific Code

Extract business rules, domain logic, and data access into plain Python modules that don't depend on Flask's request context. This makes your code portable, testable without a running server, and easier to reason about. Flask route handlers should be thin — delegate to service layer functions immediately.

# services/user_service.py — Framework-agnostic business logic
from models.user import User
from db import db
from exceptions import ValidationError

def create_user(email, name, password):
    """Pure business logic — no Flask dependencies."""
    if not email or '@' not in email:
        raise ValidationError("Invalid email address")
    
    existing = User.query.filter_by(email=email).first()
    if existing:
        raise ValidationError("Email already registered")
    
    user = User(email=email, name=name)
    user.set_password(password)
    db.session.add(user)
    db.session.commit()
    return user

# routes/users.py — Thin route handler
@users_bp.route('/', methods=['POST'])
def create_user():
    try:
        data = request.get_json()
        user = create_user(
            email=data['email'],
            name=data['name'],
            password=data['password']
        )
        return jsonify({'data': user.to_dict()}), 201
    except ValidationError as e:
        return jsonify({'error': str(e)}), 422

3. Implement Feature Flags for Gradual Rollout

Use feature flags (e.g., Flask-FeatureFlags or a simple config-based system) to toggle between legacy and new implementations at runtime. This allows you to enable Flask-powered code paths for a percentage of users or for internal testing before full rollout.

# feature_flags.py
import os
from flask import request

def use_flask_checkout():
    """Determine if this request should use the new Flask checkout flow."""
    # Internal users always get new flow
    if request.headers.get('X-Internal-Testing') == 'true':
        return True
    
    # Percentage-based rollout (20% of traffic)
    import hashlib
    user_id = request.cookies.get('user_id', '')
    if user_id:
        hash_val = int(hashlib.md5(user_id.encode()).hexdigest(), 16) % 100
        return hash_val < 20
    
    return False

4. Log Extensively and Monitor Closely

During migration, log every request with its routing destination (legacy vs. Flask), response time, and any errors. Use structured logging (e.g., with python-json-logger) so you can query logs in your observability platform. Set up alerts for increased error rates in Flask endpoints to catch migration bugs early.

5. Maintain Backward Compatibility

Never change the API contract during migration. Your Flask endpoints should accept the same request formats and return the same response structures as the legacy system. If you need to evolve the API, do it after migration is complete and the legacy system is decommissioned. Use response normalization layers if necessary to ensure exact parity.

6. Handle Configuration Cleanly

Legacy frameworks often store configuration in disparate locations — environment variables, ini files, database tables, or hardcoded constants. Consolidate all configuration in your Flask app using a single config module with environment-based switching. Use python-dotenv or Flask's built-in config.from_object and config.from_envvar.

# config.py — Centralized configuration for Flask migration
import os
from dotenv import load_dotenv

load_dotenv()

class BaseConfig:
    SECRET_KEY = os.getenv('SECRET_KEY', 'dev-key-change-in-production')
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    LEGACY_DB_URI = os.getenv('LEGACY_DB_URI')
    REDIS_URL = os.getenv('REDIS_URL', 'redis://localhost:6379/0')
    MAINTENANCE_MODE = os.getenv('MAINTENANCE_MODE', 'false').lower() == 'true'

class DevelopmentConfig(BaseConfig):
    DEBUG = True
    SQLALCHEMY_DATABASE_URI = os.getenv('DEV_DB_URI', 'sqlite:///dev.db')

class ProductionConfig(BaseConfig):
    DEBUG = False
    SQLALCHEMY_DATABASE_URI = os.getenv('PROD_DB_URI')

7. Don't Migrate Everything at Once — Prioritize by Value

Start with the simplest, highest-value endpoints: health checks, read-only API endpoints, or new features that don't exist in the legacy system yet. This builds confidence and momentum. Leave complex, stateful workflows (checkout flows, multi-step forms, real-time features) for later stages when your team is comfortable with the Flask ecosystem.

Common Pitfalls and How to Avoid Them

Conclusion

Migrating from a legacy framework to Flask is a strategic investment in your application's long-term maintainability, performance, and developer experience. By following an incremental Strangler Fig approach — inventorying routes, setting up parallel Flask applications, mapping blueprints, translating models to SQLAlchemy, and using a reverse proxy for gradual cutover — you can modernize your stack without the risks of a full rewrite. The key principles are: keep business logic framework-agnostic, maintain strict API contract compatibility during the transition, test extensively with contract tests, and use feature flags to control rollout. Flask's ecosystem of extensions gives you the flexibility to replicate exactly the features you need from your legacy framework while leaving behind the bloat and outdated patterns that slowed you down. With careful planning and the patterns outlined in this tutorial, your migration can be smooth, incremental, and ultimately transformative for your codebase.

🚀 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