Understanding the Migration: Django vs Flask
Migrating from Django to Flask means transitioning a web application from Django's "batteries-included" full-stack framework to Flask's lightweight, micro-framework architecture. Django provides an integrated experience with an ORM, admin interface, form handling, authentication, and a prescribed project structure out of the box. Flask, by contrast, offers a minimal core with extensions that you deliberately choose and assemble. This migration is not merely swapping one library for another — it involves rethinking how your application is organized, how dependencies are managed, and how architectural decisions are made.
Django follows a Model-View-Template (MVT) pattern with a tightly integrated component ecosystem. Flask gives you complete freedom to structure your project, pick your ORM (SQLAlchemy, Peewee, or none), select your templating engine (Jinja2 by default), and implement authentication however you see fit. Understanding this fundamental philosophical difference is essential before undertaking a migration.
Why Migrate from Django to Flask?
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →The decision to migrate is significant and should be driven by clear technical requirements. Here are the most common reasons teams choose Flask over Django:
- Reduced overhead: Flask starts faster, consumes less memory, and has a smaller footprint — ideal for microservices or serverless deployments where cold-start time matters.
- Architectural flexibility: Flask imposes no project structure, allowing you to organize code exactly as needed. This is valuable when Django's convention-based structure feels restrictive.
- Selective dependencies: You only include what you use. No admin panel, no automatic ORM, no middleware you didn't ask for. This leads to leaner deployments and clearer dependency graphs.
- API-first development: Flask combined with extensions like Flask-RESTful or FastAPI-like patterns excels at building REST APIs without the weight of Django's view/template machinery.
- Learning curve for new developers: Flask's simplicity makes onboarding faster for developers who only need to understand the parts the application actually uses.
- Asynchronous support: Modern Flask (2.0+) supports async views and integrates well with async libraries, offering performance benefits for I/O-bound workloads.
Before migrating, verify that Flask truly addresses your needs. If you rely heavily on Django's admin interface, its built-in migration system with Django migrations, or its robust session authentication, you will need to find or build equivalent functionality in Flask.
Pre-Migration Planning and Analysis
A successful migration starts with a thorough audit of your existing Django application. Rushing into code conversion without understanding what needs to be ported will lead to missed features and broken functionality.
Inventory Your Django App
Create a comprehensive inventory of every component in your Django project:
- All installed apps and their models, views, URLs, and templates
- Custom middleware classes and their purposes
- Django signals (pre_save, post_save, etc.) and their receivers
- Management commands in
management/commands/ - Custom template tags and filters
- Context processors
- Django REST Framework serializers and viewsets (if used)
- Celery tasks or other async job configurations
- Settings that affect behavior (authentication backends, caching, session engine)
- Third-party Django packages and their Flask equivalents
Identify Django-Specific Features
Some Django features have no direct Flask equivalent and require architectural changes:
- Django Admin: You'll need Flask-Admin, Flask-AppBuilder, or a custom admin solution.
- Django ORM migrations: You'll switch to Alembic (with SQLAlchemy) or another migration tool.
- Django Forms with model forms: Flask-WTF provides form handling but without model integration.
- Django authentication system: Flask-Login, Flask-Security, or Flask-Principal provide alternatives.
- Django sessions with database backend: Flask-Session offers similar functionality.
- Django middleware pipeline: Flask uses before/after request hooks and decorators.
- Django URL resolver with named routes: Flask's URL routing is simpler but you lose automatic reverse URL resolution.
Step-by-Step Migration Process
Step 1: Set Up the Flask Project Structure
Unlike Django's prescribed structure, Flask lets you organize freely. A recommended pattern that eases migration is to mirror Django's separation of concerns using Flask blueprints:
project/
├── app/
│ ├── __init__.py # Application factory
│ ├── config.py # Configuration classes
│ ├── models/
│ │ ├── __init__.py
│ │ ├── user.py
│ │ └── post.py
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── auth.py # Blueprint for authentication
│ │ ├── blog.py # Blueprint for blog posts
│ │ └── api.py # Blueprint for REST API
│ ├── templates/
│ │ ├── base.html
│ │ ├── auth/
│ │ │ └── login.html
│ │ └── blog/
│ │ ├── list.html
│ │ └── detail.html
│ ├── static/
│ │ ├── css/
│ │ └── js/
│ ├── forms/
│ │ ├── __init__.py
│ │ └── auth.py
│ ├── services/
│ │ ├── __init__.py
│ │ └── email.py
│ └── utils/
│ ├── __init__.py
│ └── helpers.py
├── migrations/ # Alembic migration directory
├── tests/
│ ├── conftest.py
│ ├── test_auth.py
│ └── test_blog.py
├── requirements.txt
├── wsgi.py # Entry point for production
└── manage.py # Click-based management commands
Create the application factory in app/__init__.py. This pattern is the Flask equivalent of Django's project-level setup:
# app/__init__.py
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from app.config import Config
db = SQLAlchemy()
migrate = Migrate()
login_manager = LoginManager()
def create_app(config_class=Config):
app = Flask(__name__)
app.config.from_object(config_class)
# Initialize extensions
db.init_app(app)
migrate.init_app(app, db)
login_manager.init_app(app)
login_manager.login_view = 'auth.login'
# Register blueprints
from app.routes.auth import auth_bp
from app.routes.blog import blog_bp
from app.routes.api import api_bp
app.register_blueprint(auth_bp, url_prefix='/auth')
app.register_blueprint(blog_bp, url_prefix='/blog')
app.register_blueprint(api_bp, url_prefix='/api')
return app
Create a configuration module similar to Django's settings.py:
# app/config.py
import os
from datetime import timedelta
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-change-me')
SQLALCHEMY_DATABASE_URI = os.environ.get(
'DATABASE_URL', 'sqlite:///app.db'
)
SQLALCHEMY_TRACK_MODIFICATIONS = False
SESSION_TYPE = 'filesystem'
PERMANENT_SESSION_LIFETIME = timedelta(days=31)
class ProductionConfig(Config):
DEBUG = False
TESTING = False
class DevelopmentConfig(Config):
DEBUG = True
class TestingConfig(Config):
TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
Step 2: Map Django Models to SQLAlchemy Models
This is the most critical conversion. Django's ORM uses Model classes with field definitions as class attributes. SQLAlchemy uses a declarative base with Column objects. Here is a side-by-side comparison of a Django model and its SQLAlchemy equivalent:
Original Django model:
# Django original (models.py)
from django.db import models
from django.contrib.auth.models import User
class BlogPost(models.Model):
title = models.CharField(max_length=200)
slug = models.SlugField(unique=True)
author = models.ForeignKey(User, on_delete=models.CASCADE)
body = models.TextField()
published = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
tags = models.ManyToManyField('Tag', blank=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return self.title
class Tag(models.Model):
name = models.CharField(max_length=50, unique=True)
def __str__(self):
return self.name
Flask SQLAlchemy equivalent:
# app/models/post.py
from datetime import datetime
from app import db
# Many-to-many association table for tags
post_tags = db.Table(
'post_tags',
db.Column('post_id', db.Integer, db.ForeignKey('blog_post.id'),
primary_key=True),
db.Column('tag_id', db.Integer, db.ForeignKey('tag.id'),
primary_key=True)
)
class BlogPost(db.Model):
__tablename__ = 'blog_post'
__table_args__ = {'extend_existing': True}
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
slug = db.Column(db.String(200), unique=True, nullable=False)
author_id = db.Column(db.Integer, db.ForeignKey('user.id'),
nullable=False)
body = db.Column(db.Text, nullable=False)
published = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
onupdate=datetime.utcnow)
# Relationships
author = db.relationship('User', backref=db.backref('posts', lazy='dynamic'))
tags = db.relationship('Tag', secondary=post_tags,
backref=db.backref('posts', lazy='dynamic'),
lazy='dynamic')
def __repr__(self):
return f''
class Tag(db.Model):
__tablename__ = 'tag'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), unique=True, nullable=False)
def __repr__(self):
return f''
Key mapping rules:
CharField(max_length=X)→db.Column(db.String(X))TextField()→db.Column(db.Text)IntegerField()→db.Column(db.Integer)BooleanField()→db.Column(db.Boolean)DateTimeField(auto_now_add=True)→db.Column(db.DateTime, default=datetime.utcnow)DateTimeField(auto_now=True)→db.Column(db.DateTime, onupdate=datetime.utcnow)ForeignKey(OtherModel)→db.Column(db.Integer, db.ForeignKey('other_table.id'))ManyToManyField()→ secondary table withdb.relationship()- Django's
Meta.ordering→ handled in queries with.order_by() __str__()→__repr__()in SQLAlchemy models
After defining models, generate the initial Alembic migration:
# Terminal commands
flask db init
flask db migrate -m "Initial migration from Django models"
flask db upgrade
Step 3: Convert Django Views to Flask Routes
Django uses function-based views (FBVs) or class-based views (CBVs) that return HTTP responses. Flask uses view functions decorated with @app.route() or @blueprint.route(). Here are conversion examples for common patterns:
Django function-based view:
# Django views.py
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib.auth.decorators import login_required
from .models import BlogPost
@login_required
def post_detail(request, slug):
post = get_object_or_404(BlogPost, slug=slug)
context = {'post': post, 'title': post.title}
return render(request, 'blog/detail.html', context)
def post_list(request):
posts = BlogPost.objects.filter(published=True).order_by('-created_at')
return render(request, 'blog/list.html', {'posts': posts})
Flask equivalent:
# app/routes/blog.py
from flask import Blueprint, render_template, abort
from flask_login import login_required
from app.models.post import BlogPost
blog_bp = Blueprint('blog', __name__)
@blog_bp.route('/post/')
@login_required
def post_detail(slug):
post = BlogPost.query.filter_by(slug=slug).first()
if post is None:
abort(404)
return render_template('blog/detail.html', post=post, title=post.title)
@blog_bp.route('/')
def post_list():
posts = BlogPost.query.filter_by(published=True)\
.order_by(BlogPost.created_at.desc()).all()
return render_template('blog/list.html', posts=posts)
Django class-based view conversion:
# Django CBV
from django.views.generic import ListView, DetailView
from .models import BlogPost
class PostListView(ListView):
model = BlogPost
template_name = 'blog/list.html'
context_object_name = 'posts'
queryset = BlogPost.objects.filter(published=True)
class PostDetailView(DetailView):
model = BlogPost
template_name = 'blog/detail.html'
context_object_name = 'post'
Flask equivalent using a pattern inspired by Django CBVs:
# app/routes/blog.py - Class-based approach with Flask
from flask.views import MethodView
class PostListView(MethodView):
def get(self):
posts = BlogPost.query.filter_by(published=True).all()
return render_template('blog/list.html', posts=posts)
class PostDetailView(MethodView):
def get(self, slug):
post = BlogPost.query.filter_by(slug=slug).first_or_404()
return render_template('blog/detail.html', post=post)
# Register with blueprint
blog_bp.add_url_rule('/posts', view_func=PostListView.as_view('post_list'))
blog_bp.add_url_rule('/post/',
view_func=PostDetailView.as_view('post_detail'))
Step 4: Migrate URL Patterns and Routing
Django's URL configuration uses urlpatterns lists with path() or re_path() functions. Flask uses decorators or add_url_rule(). The key difference is that Flask routes are defined alongside view functions rather than in a separate urls.py file.
Django URLs:
# Django urls.py
from django.urls import path
from . import views
urlpatterns = [
path('posts/', views.post_list, name='post_list'),
path('post//', views.post_detail, name='post_detail'),
path('post//edit/', views.post_edit, name='post_edit'),
]
Flask routes (defined in the view module):
# Flask routes on blueprint
@blog_bp.route('/posts/', endpoint='post_list')
def post_list():
# view logic
@blog_bp.route('/post/', endpoint='post_detail')
def post_detail(slug):
# view logic
@blog_bp.route('/post//edit', endpoint='post_edit')
def post_edit(pk):
# view logic
For URL generation (the equivalent of Django's {% url %} tag), Flask uses url_for() with blueprint-qualified endpoint names:
# In templates: url_for('blog.post_detail', slug=post.slug)
# In views: url_for('blog.post_list')
Step 5: Convert Django Templates to Jinja2
Both Django and Flask use Jinja2-like templating (Django has its own DTL; Flask uses Jinja2 natively). The syntax is similar but has important differences:
Django template:
{# Django template (blog/detail.html) #}
{% extends "base.html" %}
{% load static %}
{% block title %}{{ post.title }}{% endblock %}
{% block content %}
{{ post.title }}
{{ post.body|linebreaks }}
{% if post.tags.all %}
{% endif %}
Edit Post
{% endblock %}
Jinja2 Flask template:
{# Flask Jinja2 template (blog/detail.html) #}
{% extends "base.html" %}
{% block title %}{{ post.title }}{% endblock %}
{% block content %}
{{ post.title }}
{{ post.body | replace('\n', '
') | safe }}
{% if post.tags %}
{% endif %}
Edit Post
{% endblock %}
Critical template differences to address:
- Replace
{% load static %}with Flask'surl_for('static', filename='...') - Replace
{% url 'name' args %}with{{ url_for('blueprint.endpoint', arg=value) }} - Django's
{{ value|date:"format" }}becomes Python'sstrftime()or a custom filter - Django's
linebreaksfilter must be manually handled or replaced with a custom Jinja2 filter - Django's
{% if user.is_authenticated %}becomes Flask-Login's{% if current_user.is_authenticated %} - Django's
{% csrf_token %}becomes Flask-WTF's{{ form.csrf_token }}or is handled automatically - Django's
{{ form.as_p }}requires manual field rendering in Flask
Register custom Jinja2 filters in your application factory:
# Add to app/__init__.py or a separate filters module
def register_filters(app):
@app.template_filter('date_format')
def date_format(dt, fmt='%B %d, %Y'):
if dt is None:
return ''
return dt.strftime(fmt)
@app.template_filter('linebreaks')
def linebreaks(text):
if text is None:
return ''
return text.replace('\n', '
')
Step 6: Handle Authentication and Sessions
Django's authentication system is comprehensive. In Flask, you assemble authentication from extensions. Here is how to replicate Django's session-based auth using Flask-Login and Flask-Bcrypt:
User model with Flask-Login:
# app/models/user.py
from datetime import datetime
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from app import db, login_manager
class User(UserMixin, db.Model):
__tablename__ = 'user'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(150), unique=True, nullable=False)
email = db.Column(db.String(254), unique=True, nullable=False)
password_hash = db.Column(db.String(256), nullable=False)
is_active = db.Column(db.Boolean, default=True)
is_admin = db.Column(db.Boolean, default=False)
date_joined = db.Column(db.DateTime, default=datetime.utcnow)
last_login = db.Column(db.DateTime, nullable=True)
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def __repr__(self):
return f''
# Flask-Login user loader (equivalent to Django's get_user)
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
Authentication routes blueprint:
# app/routes/auth.py
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, login_required, current_user
from app.models.user import User
from app.forms.auth import LoginForm, RegistrationForm
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('blog.post_list'))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(username=form.username.data).first()
if user is None or not user.check_password(form.password.data):
flash('Invalid username or password', 'error')
return redirect(url_for('auth.login'))
login_user(user, remember=form.remember_me.data)
user.last_login = datetime.utcnow()
db.session.commit()
next_page = request.args.get('next')
return redirect(next_page or url_for('blog.post_list'))
return render_template('auth/login.html', form=form)
@auth_bp.route('/logout')
@login_required
def logout():
logout_user()
flash('You have been logged out.', 'info')
return redirect(url_for('blog.post_list'))
@auth_bp.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('blog.post_list'))
form = RegistrationForm()
if form.validate_on_submit():
user = User(username=form.username.data, email=form.email.data)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
flash('Registration complete. Please log in.', 'success')
return redirect(url_for('auth.login'))
return render_template('auth/register.html', form=form)
For session configuration (replicating Django's session engine), use Flask-Session:
# Add to app/__init__.py
from flask_session import Session
sess = Session()
def create_app(config_class=Config):
app = Flask(__name__)
app.config.from_object(config_class)
app.config['SESSION_TYPE'] = 'sqlalchemy'
app.config['SESSION_SQLALCHEMY'] = db
sess.init_app(app)
# ... rest of factory
Step 7: Migrate Forms and Validation
Django forms with model integration must be converted to Flask-WTF forms. Django's model forms that automatically generate fields from models require explicit field definitions in Flask:
Django form:
# Django forms.py
from django import forms
from .models import BlogPost
class BlogPostForm(forms.ModelForm):
class Meta:
model = BlogPost
fields = ['title', 'slug', 'body', 'published', 'tags']
widgets = {
'body': forms.Textarea(attrs={'rows': 10}),
}
Flask-WTF equivalent:
# app/forms/blog.py
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, BooleanField, SelectMultipleField
from wtforms.validators import DataRequired, Length, Optional
from app.models.tag import Tag
class BlogPostForm(FlaskForm):
title = StringField('Title', validators=[
DataRequired(), Length(min=1, max=200)
])
slug = StringField('Slug', validators=[
DataRequired(), Length(min=1, max=200)
])
body = TextAreaField('Body', validators=[DataRequired()],
render_kw={'rows': 10})
published = BooleanField('Published', default=False)
tags = SelectMultipleField('Tags', choices=[], coerce=int)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Populate tag choices dynamically (like Django's ModelMultipleChoiceField)
tags = Tag.query.order_by(Tag.name).all()
self.tags.choices = [(tag.id, tag.name) for tag in tags]
View using the form:
# app/routes/blog.py - Edit view with form
@blog_bp.route('/post//edit', methods=['GET', 'POST'])
@login_required
def post_edit(pk):
post = BlogPost.query.get_or_404(pk)
form = BlogPostForm(obj=post) # Pre-populate from model instance
if form.validate_on_submit():
form.populate_obj(post) # Updates model instance from form data
db.session.commit()
flash('Post updated successfully.', 'success')
return redirect(url_for('blog.post_detail', slug=post.slug))
return render_template('blog/edit.html', form=form, post=post)
Step 8: Convert Django Admin to Flask-Admin
If you rely on Django's admin interface, Flask-Admin provides similar functionality with SQLAlchemy integration:
# app/admin.py
from flask_admin import Admin, AdminIndexView
from flask_admin.contrib.sqla import ModelView
from flask_login import current_user
from flask import redirect, url_for
from app import db
from app.models.user import User
from app.models.post import BlogPost, Tag
class AdminRequiredMixin:
def is_accessible(self):
return current_user.is_authenticated and current_user.is_admin
def _handle_view(self, name, **kwargs):
if not self.is_accessible():
return redirect(url_for('auth.login', next=request.url))
class UserAdminView(AdminRequiredMixin, ModelView):
column_exclude_list = ['password_hash']
form_excluded_columns = ['password_hash']
column_searchable_list = ['username', 'email']
def on_model_change(self, form, model, is_created):
if form.password.data:
model.set_password(form.password.data)
class BlogPostAdminView(AdminRequiredMixin, ModelView):
column_list = ['title', 'author', 'published', 'created_at']
column_searchable_list = ['title', 'body']
form_excluded_columns = ['created_at', 'updated_at']
class AdminIndex(AdminRequiredMixin, AdminIndexView):
pass
def init_admin(app):
admin = Admin(app, name='MyApp Admin', index_view=AdminIndex(),
template_mode='bootstrap4')
admin.add_view(UserAdminView(User, db.session))
admin.add_view(BlogPostAdminView(BlogPost, db.session))
admin.add_view(ModelView(Tag, db.session))
return admin
Step 9: Migrate Management Commands
Django management commands become Click commands in Flask. Create a manage.py file:
# manage.py
import click
from flask.cli import FlaskGroup
from app import create_app, db
from app.models.user import User
app = create_app()
cli = FlaskGroup(apply='manage:app', help="Application management commands")
@cli.command('create-admin')
@click.option('--username', prompt=True)
@click.option('--email', prompt=True)
@click.option('--password', prompt=True, hide_input=True, confirmation_prompt=True)
def create_admin(username, email, password):
"""Create an admin user (equivalent to Django's createsuperuser)."""
with app.app_context():
admin = User(username=username, email=email, is_admin=True)
admin.set_password(password)
db.session.add(admin)
db.session.commit()
click.echo(f'Admin user {username} created successfully.')
@cli.command('seed-data')
def seed_data():
"""Seed the database with sample data."""
with app.app_context():
from app.models.post import BlogPost, Tag
# Create sample tags
tags = [Tag(name='python'), Tag(name='flask'), Tag(name='web')]
db.session.add_all(tags)
db.session.commit()
click.echo('Database seeded.')
if __name__ == '__main__':
cli()
Step 10: Testing and Deployment
Migrate Django tests to pytest with Flask's test client. Django's TestCase becomes pytest fixtures:
# tests/conftest.py
import pytest
from app import create_app, db
from app.config import TestingConfig
from app.models.user import User
@pytest.fixture
def app():
app = create_app(TestingConfig)
with app.app_context():
db.create_all()
yield app
db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def runner(app):
return app.test_cli_runner()
@pytest.fixture
def admin_user(app):
with app.app_context():
user = User(username='admin', email='admin@test.com', is_admin=True)
user.set_password('password123')
db.session.add(user)
db.session.commit()
return user
# tests/test_auth.py
def test_login_success(client, admin_user):
response = client.post('/auth/login', data={
'username': 'admin',
'password': 'password123'
}, follow_redirects=True)
assert response.status_code == 200
assert b'Logged in' in response.data or b'admin' in response.data
def test_protected_route_redirects_anonymous(client):
response = client.get('/blog/post/some-slug', follow_redirects=True)
assert response.status_code == 200
assert b'login' in response.data.lower()
For deployment, replace Django's WSGI setup with a Flask-compatible WSGI entry point:
# wsgi.py
from app import create_app
from app.config import ProductionConfig
app = create_app(ProductionConfig)
if __name__ == '__main__':
app.run()
Gunicorn configuration for production (equivalent to Django deployments):
# gunicorn_config.py
bind = "0.0.0.0:8000"
workers = 4
worker_class = "sync"
timeout = 120
# Run with: gunicorn -c gunicorn_config.py wsgi:app
Best Practices for Django to Flask Migration
- Migrate incrementally: Convert one Django app (or functional area) at a time. Run both applications side by side during transition using a reverse proxy to route traffic based on URL prefixes.
- Keep the database schema