Understanding the Migration from Legacy Frameworks to Django
Migrating from a legacy framework to Django involves moving an existing web application—built on older or less maintainable frameworks such as CodeIgniter, CakePHP, Flask without structure, custom PHP stacks, or even ASP.NET MVC—onto Django's robust, batteries-included ecosystem. This is not merely a rewrite; it is a strategic transformation that rearchitects your application around Django's ORM, class-based views, template system, and admin interface. The goal is to preserve business logic and data while gaining the maintainability, security, and scalability that Django offers out of the box.
What Constitutes a "Legacy Framework" in This Context
A legacy framework typically exhibits one or more of the following traits:
- No built-in ORM or reliance on raw SQL queries scattered across the codebase
- Minimal separation of concerns—business logic mixed with presentation and database access
- Outdated or unmaintained dependencies that pose security risks
- No automated testing infrastructure
- Difficulty in adding modern features like REST APIs, WebSocket support, or async tasks
- Lack of a structured migration system for schema changes
Frameworks like CodeIgniter 3, CakePHP 2, older versions of Symfony, custom PHP monoliths, and even micro-frameworks that grew organically without clear architecture all fall into this category. Django's well-defined project structure and its philosophy of "don't repeat yourself" provide a compelling upgrade path.
Why Migration to Django Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →1. Dramatic Improvement in Developer Productivity
Django's automatic admin interface alone can save weeks of development time. Instead of building CRUD dashboards from scratch, you define models and instantly get a fully functional, customizable admin panel. The ORM eliminates tedious SQL hand-writing, while class-based views encapsulate common patterns like list/detail/create/update/delete operations.
2. Long-Term Maintainability
Django enforces a clean separation between models, views, templates, and business logic. New team members can onboard quickly because the project structure follows well-known conventions. The framework's documentation is extensive, and the community follows predictable patterns, making it easier to find solutions and hire talent.
3. Security by Default
Django automatically protects against common vulnerabilities: SQL injection (via ORM parameterization), cross-site scripting (via auto-escaping templates), cross-site request forgery (via CSRF tokens), and clickjacking (via X-Frame-Options headers). Legacy frameworks often require manual implementation of these protections, and gaps are common.
4. Scalability and Modern Integrations
Django's architecture supports scaling from small projects to large enterprise applications. It integrates seamlessly with Django REST Framework for APIs, Celery for background tasks, Channels for WebSockets, and modern frontend frameworks via API-driven architectures. These integrations are standardized and well-documented, unlike the ad-hoc approaches typical in legacy stacks.
5. Schema Migrations That Actually Work
Django's migration system tracks every schema change as versioned files. You can safely roll forward, roll back, squash migrations, and maintain multiple environments in sync. Legacy frameworks often rely on manual SQL scripts or lack migration support entirely, leading to environment drift and deployment nightmares.
Planning Your Migration Strategy
Full Rewrite vs. Incremental Strangler Pattern
There are two primary approaches to migration:
- Full Rewrite (Big Bang): Build the entire application anew in Django, then cut over DNS and traffic at a single point in time. This works best for small to medium applications with clear requirements and minimal ongoing feature development.
- Strangler Pattern (Incremental): Route traffic through a reverse proxy (like Nginx) and gradually replace endpoints. New features are built in Django while legacy routes are slowly rewritten and retired. This minimizes risk and allows parallel development but requires careful routing configuration.
For most real-world projects with active user bases, the strangler pattern is strongly recommended. It allows you to validate the Django implementation piece by piece, roll back individual endpoints if issues arise, and maintain momentum on feature requests during the migration.
Auditing the Legacy Codebase
Before writing a single line of Django code, conduct a thorough audit:
- Map every URL route, HTTP method, and the expected response format
- Extract all database queries and catalog the tables, relationships, and indexing patterns
- Document authentication and authorization logic (sessions, tokens, API keys)
- Identify background jobs, cron tasks, and file upload/download handling
- List all third-party integrations (payment gateways, email providers, external APIs)
- Capture business rules hidden in controller code, helper functions, or even view templates
This audit produces a migration backlog that you can prioritize and estimate. It also reveals "dead code"—endpoints or features that are no longer used and can be safely omitted from the Django rebuild.
Step-by-Step Migration Walkthrough
Step 1: Scaffold Your Django Project
Create the Django project and configure it for your target environment. Use a virtual environment and pin dependencies immediately.
# Create virtual environment and install Django
python -m venv venv
source venv/bin/activate
pip install django~=4.2
# Create project and first app
django-admin startproject myproject
cd myproject
python manage.py startapp core
Configure settings with environment variables, not hard-coded values. Use python-decouple or environs for this purpose from day one.
Step 2: Model the Database in Django's ORM
This is the most critical phase. You must translate the legacy database schema into Django models. If the legacy database uses MySQL and you plan to keep it during migration, use inspectdb to generate initial models, then refine them heavily.
python manage.py inspectdb --database=legacy_connection > legacy_models_raw.py
However, for a true migration, you should design clean Django models and create migrations that build the schema fresh. Here's an example of translating a legacy PHP application's user and order tables into Django models:
# core/models.py
from django.db import models
from django.contrib.auth.models import AbstractUser
from django.utils import timezone
class User(AbstractUser):
"""Extends Django's built-in User model with legacy fields."""
legacy_id = models.IntegerField(unique=True, null=True, blank=True,
help_text="Original user ID from legacy system")
phone_number = models.CharField(max_length=20, blank=True)
date_of_birth = models.DateField(null=True, blank=True)
is_verified = models.BooleanField(default=False)
created_at_legacy = models.DateTimeField(null=True, blank=True)
def __str__(self):
return self.email or self.username
class Order(models.Model):
"""Represents a customer order migrated from legacy orders table."""
STATUS_CHOICES = [
('pending', 'Pending'),
('confirmed', 'Confirmed'),
('shipped', 'Shipped'),
('delivered', 'Delivered'),
('cancelled', 'Cancelled'),
]
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='orders'
)
legacy_order_id = models.IntegerField(unique=True, null=True, blank=True)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
total_amount = models.DecimalField(max_digits=10, decimal_places=2)
shipping_address = models.TextField()
placed_at = models.DateTimeField(default=timezone.now)
legacy_created_at = models.DateTimeField(null=True, blank=True)
notes = models.TextField(blank=True)
class Meta:
ordering = ['-placed_at']
indexes = [
models.Index(fields=['status', 'placed_at']),
]
def __str__(self):
return f"Order #{self.id} - {self.status}"
@property
def is_shippable(self):
return self.status in ('confirmed', 'pending')
Notice the legacy_id and legacy_order_id fields. These are crucial for data migration—they allow you to map old records to new ones, update foreign key references, and maintain audit trails back to the original system.
Step 3: Write Data Migration Scripts
Django's migration system supports data migrations (as opposed to schema migrations). Create a migration that reads from the legacy database (which you can connect to as a secondary database) and populates the new Django models.
First, configure the legacy database in settings:
# settings.py
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'new_django_db',
'USER': 'db_user',
'PASSWORD': 'db_password',
'HOST': 'localhost',
},
'legacy': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'legacy_app_db',
'USER': 'legacy_user',
'PASSWORD': 'legacy_password',
'HOST': 'legacy_host',
'OPTIONS': {
'charset': 'utf8mb4',
},
}
}
Now create a data migration that transfers users:
# core/management/commands/migrate_legacy_users.py
from django.core.management.base import BaseCommand
from django.contrib.auth.hashers import make_password
from core.models import User
import MySQLdb
class Command(BaseCommand):
help = 'Migrate users from legacy MySQL database to Django User model'
def handle(self, *args, **options):
# Connect to legacy database directly
legacy_conn = MySQLdb.connect(
host='legacy_host',
user='legacy_user',
passwd='legacy_password',
db='legacy_app_db',
charset='utf8mb4'
)
cursor = legacy_conn.cursor(MySQLdb.cursors.DictCursor)
# Fetch all legacy users
cursor.execute("""
SELECT id, email, password_hash, first_name, last_name,
phone, date_of_birth, is_verified, created_at
FROM legacy_users
WHERE deleted_at IS NULL
""")
migrated_count = 0
skipped_count = 0
for legacy_user in cursor.fetchall():
# Check if already migrated
if User.objects.filter(legacy_id=legacy_user['id']).exists():
skipped_count += 1
continue
# Create Django user
user = User(
legacy_id=legacy_user['id'],
email=legacy_user['email'],
username=legacy_user['email'], # Use email as username
first_name=legacy_user.get('first_name', ''),
last_name=legacy_user.get('last_name', ''),
phone_number=legacy_user.get('phone', ''),
date_of_birth=legacy_user.get('date_of_birth'),
is_verified=bool(legacy_user.get('is_verified', 0)),
created_at_legacy=legacy_user.get('created_at'),
)
# Handle password migration
# Legacy may use bcrypt, md5, or custom hashing
# Store legacy hash in a separate field or use Django's setter
if legacy_user['password_hash']:
# Option 1: Set unusable password and force reset
user.set_unusable_password()
# Option 2: Use a custom authentication backend
# that checks legacy hash, then upgrades on success
user.password = f"legacy_migration_{legacy_user['password_hash']}"
user.save()
migrated_count += 1
legacy_conn.close()
self.stdout.write(
self.style.SUCCESS(
f"Migrated {migrated_count} users, skipped {skipped_count} already existing"
)
)
For order data migration, you'll need to resolve foreign keys to the newly migrated users:
# core/management/commands/migrate_legacy_orders.py
from django.core.management.base import BaseCommand
from core.models import User, Order
import MySQLdb
from decimal import Decimal
class Command(BaseCommand):
help = 'Migrate orders from legacy database, linking to migrated users'
def handle(self, *args, **options):
legacy_conn = MySQLdb.connect(
host='legacy_host',
user='legacy_user',
passwd='legacy_password',
db='legacy_app_db',
charset='utf8mb4'
)
cursor = legacy_conn.cursor(MySQLdb.cursors.DictCursor)
cursor.execute("""
SELECT o.id, o.user_id, o.status, o.total,
o.shipping_address, o.created_at, o.notes
FROM legacy_orders o
JOIN legacy_users u ON o.user_id = u.id
WHERE u.deleted_at IS NULL
AND o.deleted_at IS NULL
""")
migrated = 0
errors = []
for legacy_order in cursor.fetchall():
if Order.objects.filter(legacy_order_id=legacy_order['id']).exists():
continue
try:
# Resolve the user foreign key
user = User.objects.get(legacy_id=legacy_order['user_id'])
except User.DoesNotExist:
errors.append(
f"Order {legacy_order['id']}: User {legacy_order['user_id']} not found"
)
continue
status_map = {
'0': 'pending',
'1': 'confirmed',
'2': 'shipped',
'3': 'delivered',
'99': 'cancelled',
}
Order.objects.create(
user=user,
legacy_order_id=legacy_order['id'],
status=status_map.get(str(legacy_order.get('status', '0')), 'pending'),
total_amount=Decimal(str(legacy_order.get('total', 0))),
shipping_address=legacy_order.get('shipping_address', ''),
legacy_created_at=legacy_order.get('created_at'),
notes=legacy_order.get('notes', ''),
)
migrated += 1
legacy_conn.close()
self.stdout.write(self.style.SUCCESS(f"Migrated {migrated} orders"))
if errors:
for error in errors:
self.stdout.write(self.style.WARNING(error))
Step 4: Implement a Custom Authentication Backend
Users migrated from the legacy system may have passwords hashed with algorithms Django doesn't natively support. Rather than forcing all users to reset their passwords, implement a custom authentication backend that validates against the legacy hash and then upgrades it to Django's native format.
# core/auth_backends.py
from django.contrib.auth.backends import BaseBackend
from django.contrib.auth.hashers import check_password, make_password
from core.models import User
import hashlib
import binascii
class LegacyPasswordBackend(BaseBackend):
"""
Authenticates against the legacy password hash stored during migration.
On successful authentication, upgrades the hash to Django's native format.
"""
def authenticate(self, request, username=None, password=None, **kwargs):
try:
user = User.objects.get(email=username)
except User.DoesNotExist:
return None
# Check if password field starts with legacy marker
if user.password and user.password.startswith('legacy_migration_'):
legacy_hash = user.password.replace('legacy_migration_', '')
# Example: legacy uses SHA-256 with a static salt
# Adapt this to match YOUR legacy system's actual algorithm
if self._verify_legacy_sha256(password, legacy_hash):
# Upgrade to Django's native hasher
user.password = make_password(password)
user.save(update_fields=['password'])
return user
return None
# Fall back to Django's normal password checking
if check_password(password, user.password):
return user
return None
def _verify_legacy_sha256(self, plaintext, stored_hash):
# Example legacy verification: SHA-256(salt + password)
# The stored hash format might be "salt:hash" or similar
if ':' in stored_hash:
salt, hash_part = stored_hash.split(':', 1)
else:
salt = 'legacy_fixed_salt'
hash_part = stored_hash
computed = hashlib.sha256(
(salt + plaintext).encode('utf-8')
).hexdigest()
return computed == hash_part
def get_user(self, user_id):
try:
return User.objects.get(pk=user_id)
except User.DoesNotExist:
return None
Register this backend in settings:
# settings.py
AUTHENTICATION_BACKENDS = [
'core.auth_backends.LegacyPasswordBackend',
'django.contrib.auth.backends.ModelBackend',
]
This approach provides a seamless user experience—users log in with their old credentials once, and behind the scenes their password is upgraded to Django's secure, salted bcrypt (or PBKDF2) format.
Step 5: Convert Legacy Views to Django Views
Legacy controllers typically mix HTTP handling, business logic, and database queries. In Django, you separate these concerns. Here's an example of converting a typical legacy PHP controller that fetches a user's order history into a Django class-based view:
# Legacy CodeIgniter controller (for reference)
# public function order_history($user_id) {
# $this->db->where('user_id', $user_id);
# $query = $this->db->get('orders');
# $data['orders'] = $query->result_array();
# $this->load->view('order_history', $data);
# }
Here's the equivalent Django implementation:
# core/views.py
from django.views.generic import ListView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.paginator import Paginator
from core.models import Order
class OrderHistoryView(LoginRequiredMixin, ListView):
"""Displays paginated order history for the authenticated user."""
model = Order
template_name = 'core/order_history.html'
context_object_name = 'orders'
paginate_by = 20
def get_queryset(self):
# Automatically filtered to logged-in user
return Order.objects.filter(
user=self.request.user
).select_related('user').order_by('-placed_at')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Add summary statistics
context['total_orders'] = self.get_queryset().count()
context['pending_count'] = self.get_queryset().filter(
status='pending'
).count()
return context
For the order detail view, which replaces a legacy controller method that fetched a single order and its line items:
# core/views.py (continued)
from django.views.generic import DetailView
from django.shortcuts import get_object_or_404
from django.http import HttpResponseForbidden
class OrderDetailView(LoginRequiredMixin, DetailView):
model = Order
template_name = 'core/order_detail.html'
context_object_name = 'order'
def get_object(self, queryset=None):
order = get_object_or_404(
Order,
pk=self.kwargs['order_id'],
user=self.request.user # Ensure user owns this order
)
return order
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Fetch related line items if they exist
order = self.object
context['line_items'] = order.items.all() if hasattr(order, 'items') else []
context['can_cancel'] = order.status in ('pending', 'confirmed')
return context
Step 6: Map Legacy URLs and Set Up Redirects
Legacy URLs often follow patterns like /index.php/controller/method/param or /?p=controller&action=method. You need to capture these in Django's URL configuration and either serve the equivalent page or issue permanent redirects to the new URL structure.
# core/urls.py
from django.urls import path, re_path
from django.views.generic import RedirectView
from core import views
urlpatterns = [
# New Django URL patterns
path('orders/', views.OrderHistoryView.as_view(), name='order_history'),
path('orders//', views.OrderDetailView.as_view(), name='order_detail'),
# Legacy URL redirects
re_path(
r'^index\.php/orders/history/(?P\d+)/?$',
views.LegacyOrderRedirect.as_view(),
name='legacy_order_history'
),
re_path(
r'^orders/view/(?P\d+)/?$',
RedirectView.as_view(
pattern_name='order_detail',
permanent=True
),
name='legacy_order_detail'
),
]
# Custom redirect view for complex legacy URL patterns
class LegacyOrderRedirect(RedirectView):
permanent = True
pattern_name = 'order_history'
def get_redirect_url(self, *args, **kwargs):
# Log the legacy URL hit for analytics
import logging
logger = logging.getLogger('migration')
logger.info(f"Legacy URL accessed: user_id={kwargs.get('user_id')}")
return super().get_redirect_url(*args, **kwargs)
Step 7: Migrate Templates with Django's Template Language
Legacy templates often contain inline PHP, complex logic, and raw database queries. Django templates enforce a clear separation. Here's a side-by-side comparison of a legacy PHP template and its Django equivalent:
<?php foreach($orders as $order): ?>
<div class="order">
<h3>Order #<?php echo $order['id']; ?></h3>
<p>Status: <?php echo strtoupper($order['status']); ?></p>
<p>Total: $<?php echo number_format($order['total'], 2); ?></p>
<a href="/orders/view/<?php echo $order['id']; ?>">View Details</a>
</div>
<?php endforeach; ?>
Here's the Django template:
{# core/templates/core/order_history.html #}
{% extends "base.html" %}
{% block title %}Order History{% endblock %}
{% block content %}
Your Orders
{% if orders %}
{% for order in orders %}
Order #{{ order.id }}
Status: {{ order.get_status_display }}
Total: ${{ order.total_amount|floatformat:2 }}
Placed: {{ order.placed_at|date:"F j, Y" }}
View Details →
{% endfor %}
{# Pagination #}
{% if is_paginated %}
{% if page_obj.has_previous %}
← Previous
{% endif %}
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
{% if page_obj.has_next %}
Next →
{% endif %}
{% endif %}
{% else %}
You haven't placed any orders yet.
{% endif %}
{% endblock %}
Step 8: Implement Middleware for Gradual Traffic Shifting
During the strangler pattern migration, you may want to route a percentage of traffic to the new Django app while keeping the legacy system as fallback. Here's a middleware-based approach:
# core/middleware.py
import random
import logging
from django.http import HttpResponseRedirect
logger = logging.getLogger('migration')
class TrafficShiftMiddleware:
"""
Gradually shifts traffic from legacy to new Django endpoints.
Configure TRAFFIC_SHIFT_PERCENTAGE in settings (0-100).
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# Only apply to certain paths
shift_paths = ['/orders/', '/account/', '/products/']
should_shift = any(request.path.startswith(p) for p in shift_paths)
if should_shift:
from django.conf import settings
percentage = getattr(settings, 'TRAFFIC_SHIFT_PERCENTAGE', 10)
if random.randint(1, 100) <= percentage:
# Let request proceed to Django view
logger.debug(f"Serving via Django: {request.path}")
response = self.get_response(request)
# Add header for debugging
response['X-Served-By'] = 'Django'
return response
# Otherwise, fall through to legacy proxy logic
# (This would typically be handled at the Nginx level,
# but this middleware can add headers or log decisions)
return self.get_response(request)
Best Practices for a Successful Migration
1. Maintain a Comprehensive Test Suite
Write tests for every migrated endpoint before you consider it complete. Django's test framework makes this straightforward:
# core/tests/test_order_views.py
from django.test import TestCase, Client
from django.urls import reverse
from core.models import User, Order
class OrderViewTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username='testuser@example.com',
email='testuser@example.com',
password='testpass123'
)
self.client = Client()
self.client.login(username='testuser@example.com', password='testpass123')
# Create sample orders
for i in range(5):
Order.objects.create(
user=self.user,
status='pending' if i % 2 == 0 else 'delivered',
total_amount=50.00 + i * 10,
shipping_address=f'{i} Main St'
)
def test_order_history_requires_login(self):
self.client.logout()
response = self.client.get(reverse('order_history'))
self.assertEqual(response.status_code, 302)
def test_order_history_shows_only_user_orders(self):
# Create another user's order
other_user = User.objects.create_user('other@example.com', 'other@example.com', 'pass')
Order.objects.create(user=other_user, status='pending', total_amount=99, shipping_address='X')
response = self.client.get(reverse('order_history'))
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.context['orders']), 5) # Only our 5
def test_order_detail_own_order(self):
order = Order.objects.filter(user=self.user).first()
response = self.client.get(reverse('order_detail', args=[order.id]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, f"Order #{order.id}")
def test_order_detail_other_user_forbidden(self):
other_user = User.objects.create_user('other@example.com', 'other@example.com', 'pass')
other_order = Order.objects.create(user=other_user, status='pending', total_amount=99, shipping_address='Y')
response = self.client.get(reverse('order_detail', args=[other_order.id]))
self.assertEqual(response.status_code, 404) # get_object_or_404 returns 404
2. Use Feature Flags for Gradual Rollout
Rather than hard-coding which system serves a request, use a feature flag system that operations can toggle without redeploying code:
# core/feature_flags.py
from django.conf import settings
import redis
class FeatureFlagService:
"""Checks feature flags stored in Redis for real-time toggling."""
def __init__(self):
self.redis = redis.Redis(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
decode_responses=True
)
def is_enabled(self, flag_name, user_id=None):
# Check if flag exists and is true
value = self.redis.get(f"feature:{flag_name}")
if value is None:
return False
if value.lower() == 'true':
return True
# Could also check user-specific overrides
if user_id and self.redis.get(f"feature:{flag_name}:user:{user_id}") == 'true':
return True
return False
def use_django_checkout(self, user_id=None):
return self.is_enabled('django_checkout', user_id)
3. Log Everything During the Transition
Comprehensive logging is essential to detect issues early. Log every legacy URL hit, every authentication through the custom backend, and every data migration run:
# settings.py
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'migration': {
'format': '[MIGRATION] {asctime} {levelname} {module} {message}',
'style': '{',
},
},
'handlers': {
'migration_file': {
'level': 'INFO',
'class': 'logging.handlers.RotatingFileHandler',
'filename': '/var/log/django/migration.log',
'maxBytes': 10485760, # 10 MB
'backupCount': 5,
'formatter': 'migration',
},
'console': {
'level': 'DEBUG',
'class': 'logging.StreamHandler',
'formatter': 'migration',
},
},
'loggers': {
'migration': {
'handlers': ['migration_file', 'console'],
'level': 'INFO',
'propagate': False,
},
},
}
4. Run Legacy and New Systems in Parallel
For critical endpoints, run both systems simultaneously and compare responses. This "dark traffic" or "shadow testing" approach catches discrepancies:
# core/shadow_testing.py
import requests
from django.conf import settings
import logging
logger = logging.getLogger('migration')
def shadow_compare_orders(user_id):
"""
Fetches orders from both legacy and Django APIs and compares results.
Logs discrepancies without affecting production traffic.
"""
try:
# Call legacy API
legacy_response = requests.get(
f"{settings.LEGACY_API_URL}/orders/{user_id}",
timeout=5
)
legacy_data = legacy_response.json()
except Exception as e:
logger.warning(f"Shadow test: legacy API unavailable: {e}")
return
# Call Django (internal or via localhost)
from django.test.client import Client
from django.contrib.auth import get_user_model
User = get_user_model()
user = User.objects.get(legacy_id=user_id)
client = Client()
# Simulate authenticated request
client.force_login(user)
django_response = client.get(f'/orders/')
# Compare key metrics
legacy_count = len(legacy_data.get('orders', []))
django_count = django_response.context.get('total_orders', 0)
if legacy_count != django_count:
logger.error(
f"Shadow mismatch: user {user_id} - "
f"legacy shows {legacy_count} orders, Django shows {django_count}"
)
else:
logger.debug(f"Shadow match: user {user_id} - both show {legacy_count} orders")
5. Establish a Rollback Plan for Every Phase
Every migration step should be reversible. For database migrations, Django provides python manage.py migrate app_name migration_number --database=default rollback capability. For traffic routing, keep the legacy deployment running and simply revert the load balancer configuration. Document rollback procedures in your migration runbook, not just in code comments.
6. Keep the Legacy System Read-Only After Cutover
Once you fully cut over to Django, keep the legacy database available in read-only mode for at least 30 days. This allows you to verify data integrity, retrieve historical records that may have been missed, and satisfy any audit requirements. Implement this at the database level:
-- On legacy MySQL database after cutover
REVOKE INSERT, UPDATE, DELETE ON legacy_app_db.* FROM 'app_user'@'%';
GRANT SELECT ON legacy_app_db.* TO 'app_user'@'%';
FLUSH PRIVILEGES;
Common Pitfalls and How to Avoid Them
Pitfall 1: Underestimating Data Integrity Issues
Legacy databases often contain inconsistent data—orphaned records, duplicate entries, invalid foreign keys, and data that violates implicit business rules. Your migration scripts must handle these gracefully. Use Django's transaction.atomic decorator to wrap each migration batch, and implement a quarantine table for problematic records:
# core/models.py (quarantine model)
class MigrationQuarantine(models.Model):
legacy_table = models.CharField(max_length=100)
legacy_id = models.IntegerField()
raw_data = models.JSONField()
error_message = models.TextField()
created