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
- Django → Flask + SQLAlchemy + WTForms + Flask-Login: Django's ORM, forms, and auth system map well to these Flask extensions
- Pyramid → Flask: Both are WSGI-based, but Flask's routing is simpler and more explicit
- web2py → Flask + custom DAL wrapper or SQLAlchemy: web2py's database abstraction layer can be replaced or wrapped
- CodeIgniter / Laravel (PHP) → Flask: Full language migration; routes, controllers, and views need complete rewrites
- Express.js (Node) → Flask: Similar microframework philosophy, making the migration conceptually straightforward
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 }}
{{ user.email }}
{% if user.is_verified %}
Verified
{% else %}
Pending Verification
{% endif %}
Recent Orders
{% if orders %}
{% for order in orders %}
-
Order #{{ order.id }} — {{ order.total | format_currency }}
{{ order.created_at | humanize_date }}
{% endfor %}
{% 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
- Circular imports: Use the application factory pattern and avoid importing
dborappat module level in route files. Import within functions or use blueprints with deferred app registration. - Session mismatch: Legacy sessions (Django sessions, PHP sessions) won't carry over to Flask automatically. Implement a bridge endpoint that validates legacy session tokens and issues new Flask-Login sessions.
- ORM performance differences: SQLAlchemy lazy loading patterns differ from Django's queryset behavior. Profile your queries and use eager loading (
joinedload,selectinload) to avoid N+1 problems. - Background tasks: If your legacy app uses Celery, you can keep Celery and integrate it with Flask. If it uses framework-specific task queues, migrate to Celery or RQ with Flask integration.
- File uploads and static assets: Ensure your Flask app has access to the same storage backends. Use Flask's
send_fileandurl_forfor static file serving, or offload to S3/CloudFront entirely.
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.