← Back to DevBytes

Migrating from Django to Flask: Step-by-Step Guide

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:

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:

Identify Django-Specific Features

Some Django features have no direct Flask equivalent and require architectural changes:

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:

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 }}

By {{ post.author.username }} on {{ post.created_at|date:"F j, Y" }}

{{ post.body|linebreaks }}
{% if post.tags.all %}
{% for tag in post.tags.all %} {{ tag.name }} {% endfor %}
{% 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 }}

By {{ post.author.username }} on {{ post.created_at.strftime('%B %d, %Y') }}

{{ post.body | replace('\n', '
') | safe }}
{% if post.tags %}
{% for tag in post.tags %} {{ tag.name }} {% endfor %}
{% endif %} Edit Post {% endblock %}

Critical template differences to address:

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

🚀 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