How to Migrate a Ruby on Rails App to Python Django

Type: Software Reference Confidence: 0.88 Sources: 8 Verified: 2026-02-22 Freshness: quarterly

TL;DR

Constraints

Quick Reference

Rails PatternDjango EquivalentExample
rails new myappdjango-admin startproject myprojectCreates project with settings.py, urls.py, wsgi.py, manage.py [src1]
rails generate model UserDefine class in models.py + python manage.py makemigrationsDjango auto-detects model changes and generates migrations [src1, src5]
rails generate scaffold Postpython manage.py startapp posts + manual model/view/templateDjango has no scaffold; create model, view, URL conf, and template separately [src4]
config/routes.rb (resourceful routes)urls.py with path() / re_path()path('posts/', PostListView.as_view(), name='post-list') [src1, src4]
ActiveRecord (ORM)Django ORM (django.db.models)Post.objects.filter(published=True).order_by('-created_at') [src1, src7]
db:migrate / db:rollbackpython manage.py migrate / migrate app 0003Auto-generates migration files from model changes [src1, src3]
before_action / after_actionMiddleware classes or decorators@login_required decorator or custom Middleware.__call__() [src1, src4]
ERB / Haml templatesDjango Template Language (DTL) or Jinja2{% for post in posts %}{{ post.title }}{% endfor %} [src1, src8]
Devise (authentication)django.contrib.auth (built-in)from django.contrib.auth import authenticate, login [src1, src7]
Sidekiq / Solid Queue (background jobs)Celery + Redis or Django 6.0 django.tasks (built-in)@task def send_email(user_id): ... or @shared_task [src1, src7]
ActionMailerdjango.core.mail (built-in, modernized in 6.0)send_mail('Subject', 'Body', '[email protected]', ['[email protected]']) [src1]
RSpec / Minitestpytest-django / django.test.TestCaseclass PostTestCase(TestCase): def test_create(self): ... [src1, src4]
Pundit / CanCanCandjango.contrib.auth.permissions + django-guardian@permission_required('posts.add_post') or object-level perms [src1]
ActiveSupport::ConcernPython mixins / abstract modelsclass TimestampMixin(models.Model): class Meta: abstract = True [src4, src5]
Sprockets / Propshaftdjango.contrib.staticfiles + Vite/Webpack{% static 'css/style.css' %} or django-vite [src1, src8]
Rails.credentials / dotenvdjango-environ / python-decoupleenv = environ.Env(); SECRET_KEY = env('SECRET_KEY') [src1]
Solid Cache (Rails 8)Django cache framework (django.core.cache)cache.set('key', 'value', 300) -- supports Redis, Memcached, DB [src1, src3]

Decision Tree

START: Should I migrate from Rails to Django?
|-- Is the migration driven by Python ecosystem needs (ML, data science, AI)?
|   |-- YES -> Strong fit -- Django integrates natively with NumPy, pandas, scikit-learn, PyTorch [src6, src8]
|   +-- NO |
|-- Is the team already proficient in Python?
|   |-- YES -> Migrate incrementally via strangler fig (reverse proxy splits traffic) [src4]
|   +-- NO |
|       |-- Is the team willing to invest 2-4 weeks learning Python?
|       |   |-- YES -> Invest in training first, then migrate [src6]
|       |   +-- NO -> Stay on Rails or consider Node.js instead
|-- Does the app need Django's built-in admin panel?
|   |-- YES -> Django admin saves weeks of CRUD development for internal tools [src1, src7]
|   +-- NO |
|-- Is the app API-only (no server-rendered HTML)?
|   |-- YES -> Use Django REST Framework -- closest to Rails API mode [src4]
|   |   |-- Need async support?
|   |   |   |-- YES -> Django 5.2+/6.0 has native async views and ORM [src1, src2]
|   |   |   +-- NO -> Standard DRF with sync views
|   +-- NO -> Django templates replace ERB; simpler than you think [src5]
|-- Is the app CPU-heavy (ML inference, data processing)?
|   |-- YES -> Django is a strong fit -- Python excels at computation [src6, src8]
|   +-- NO |
|-- Is the Rails app on Rails 8 with Solid Queue/Cache and working well?
|   |-- YES -> Weigh migration cost carefully -- Rails 8 eliminated many pain points [src3]
|   +-- NO |
|-- Is the Rails app small (<20 models) and stable with no strategic Python need?
|   |-- YES -> Keep Rails -- migration cost likely exceeds benefit
|   +-- NO |
+-- DEFAULT -> Migrate if Python/Django alignment exists. Otherwise, stay on Rails.

Step-by-Step Guide

1. Audit the Rails application and map domain boundaries

Inventory every Rails model, controller, background job, mailer, and gem dependency. Map each to a Django equivalent. Identify which Rails gems have Django/Python counterparts and which require custom solutions. Pay special attention to Rails 8 Solid Queue/Cache jobs -- these map to Django 6.0's built-in django.tasks or Celery. [src4, src5]

# List all Rails models
find app/models -name "*.rb" | wc -l

# List all controllers and actions
grep -r "def " app/controllers/ --include="*.rb" | grep -v "#"

# List all gem dependencies (identify Python equivalents)
cat Gemfile | grep "gem '" | awk -F"'" '{print $2}'

# List all Sidekiq/Solid Queue workers
find app/jobs -name "*.rb" | wc -l

# Export current database schema
rails db:schema:dump
cat db/schema.rb

Verify: You should have a spreadsheet mapping every Rails model to a Django model, every gem to a Python package, and every controller action to a Django view.

2. Set up the Django project alongside the Rails app

Create a new Django project pointing at the same PostgreSQL database. Use inspectdb to generate Django models from the existing Rails schema. [src1, src5]

# Create virtual environment and install Django
python -m venv venv
source venv/bin/activate
pip install django psycopg[binary] django-environ

# Create Django project
django-admin startproject myproject .

# Introspect existing Rails database to generate Django models
python manage.py inspectdb > models_generated.py

# Create your first app
python manage.py startapp posts

Verify: python manage.py inspectdb outputs Django model classes matching your Rails schema. python manage.py check reports no errors.

3. Migrate models from ActiveRecord to Django ORM

Convert the auto-generated inspectdb output into proper Django models. Replace ActiveRecord validations with Django field validators, callbacks with model save() overrides or signals, and scopes with custom QuerySet managers. Set db_table on each model's Meta to match existing Rails table names. [src1, src3, src5]

# posts/models.py -- migrated from Rails Post model
from django.db import models
from django.utils.text import slugify


class PostQuerySet(models.QuerySet):
    """Replaces ActiveRecord scopes."""
    def published(self):
        return self.filter(published=True)

    def recent(self):
        return self.order_by('-created_at')[:10]


class Post(models.Model):
    title = models.CharField(max_length=255)
    slug = models.SlugField(unique=True, max_length=255)
    body = models.TextField()
    published = models.BooleanField(default=False)
    author = models.ForeignKey(
        'users.User',
        on_delete=models.CASCADE,
        related_name='posts',
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    objects = PostQuerySet.as_manager()

    class Meta:
        db_table = 'posts'  # match existing Rails table name
        ordering = ['-created_at']

    def save(self, *args, **kwargs):
        """Replaces before_save :generate_slug callback."""
        if not self.slug:
            self.slug = slugify(self.title)
        super().save(*args, **kwargs)

    def __str__(self):
        return self.title

Verify: python manage.py makemigrations --check reports no changes (models match existing DB).

4. Migrate controllers and routing to Django views and URL conf

Convert Rails controllers to Django class-based views (CBVs) or function-based views (FBVs). Map config/routes.rb to Django urls.py. Replace before_action filters with decorators or middleware. [src1, src4]

# posts/views.py -- migrated from Rails PostsController
from django.views.generic import ListView, DetailView, CreateView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from .models import Post
from .forms import PostForm


class PostListView(ListView):
    model = Post
    queryset = Post.objects.published()
    template_name = 'posts/post_list.html'
    paginate_by = 20  # replaces will_paginate / kaminari


class PostDetailView(DetailView):
    model = Post
    template_name = 'posts/post_detail.html'


class PostCreateView(LoginRequiredMixin, CreateView):
    model = Post
    form_class = PostForm
    template_name = 'posts/post_form.html'
    success_url = reverse_lazy('post-list')

    def form_valid(self, form):
        form.instance.author = self.request.user
        return super().form_valid(form)


# posts/urls.py -- replaces config/routes.rb resource :posts
from django.urls import path
from . import views

urlpatterns = [
    path('', views.PostListView.as_view(), name='post-list'),
    path('new/', views.PostCreateView.as_view(), name='post-create'),
    path('<slug:slug>/', views.PostDetailView.as_view(), name='post-detail'),
]

Verify: python manage.py runserver and visit http://localhost:8000/posts/ to see the post list.

5. Migrate background jobs from Sidekiq/Solid Queue to Celery or Django Tasks

For Django 6.0: use the new built-in django.tasks framework for simple background work. For complex job pipelines, use Celery with Redis. Replace ActionMailer with Django's built-in send_mail (modernized email API in 6.0). [src1, src7]

# === OPTION A: Django 6.0 built-in tasks (simple background work) ===

# myproject/settings.py
TASKS = {
    "default": {
        "BACKEND": "django.tasks.backends.database.DatabaseBackend",
    }
}

# posts/tasks.py
from django.tasks import task
from django.core.mail import send_mail

@task
def send_welcome_email(user_id):
    from users.models import User
    user = User.objects.get(id=user_id)
    send_mail('Welcome!', f'Hello {user.first_name}!',
              '[email protected]', [user.email])

# Usage: send_welcome_email.enqueue(user_id=user.id)


# === OPTION B: Celery (complex job pipelines, retries, scheduling) ===

# myproject/celery.py
import os
from celery import Celery

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
app = Celery('myproject')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()

# Usage: send_welcome_email.delay(user.id)

Verify: For Django Tasks: python manage.py runtasks starts the task worker. For Celery: celery -A myproject worker --loglevel=info starts without errors.

6. Set up reverse proxy for incremental cutover

Run both Rails (port 3000) and Django (port 8000) behind nginx. Route migrated endpoints to Django, everything else stays on Rails. Cut over one resource at a time (strangler fig pattern). [src4]

# Migrated endpoints -> Django
location /posts {
    proxy_pass http://127.0.0.1:8000;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
}

location /admin {
    proxy_pass http://127.0.0.1:8000;
    proxy_set_header Host $host;
}

# Everything else -> Rails (shrinks over time)
location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host $host;
}

Verify: curl -I http://myapp.com/posts/ returns Django response headers; legacy URLs return Rails response headers.

7. Migrate authentication and decommission Rails

Port Devise to Django's built-in auth system. Django includes authentication, password hashing, sessions, and permissions out of the box. For bcrypt hash compatibility with Devise, configure Django's PASSWORD_HASHERS. [src1, src7]

# users/views.py -- replaces Devise sessions controller
from django.contrib.auth import authenticate, login
from django.shortcuts import render, redirect


def login_view(request):
    """Replaces Devise SessionsController#create."""
    if request.method == 'POST':
        email = request.POST['email']
        password = request.POST['password']
        user = authenticate(request, username=email, password=password)
        if user is not None:
            login(request, user)
            return redirect('post-list')
        return render(request, 'users/login.html', {'error': 'Invalid credentials'})
    return render(request, 'users/login.html')


# settings.py -- password hashers (Devise bcrypt compatibility)
PASSWORD_HASHERS = [
    'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
    'django.contrib.auth.hashers.BCryptPasswordHasher',
    'django.contrib.auth.hashers.PBKDF2PasswordHasher',
]

Verify: Login with existing Rails/Devise credentials works in Django.

Code Examples

Django REST Framework: Full API ViewSet (equivalent of Rails API scaffold)

# Input:  HTTP requests to /api/v1/posts/
# Output: JSON responses with CRUD operations, pagination, filtering
# pip install djangorestframework django-filter

# posts/serializers.py
from rest_framework import serializers
from .models import Post

class PostSerializer(serializers.ModelSerializer):
    author_name = serializers.CharField(source='author.get_full_name', read_only=True)

    class Meta:
        model = Post
        fields = ['id', 'title', 'slug', 'body', 'published',
                  'author', 'author_name', 'created_at', 'updated_at']
        read_only_fields = ['slug', 'author', 'created_at', 'updated_at']

# posts/views_api.py
from rest_framework import viewsets, permissions, filters
from rest_framework.pagination import PageNumberPagination
from django_filters.rest_framework import DjangoFilterBackend

class PostPagination(PageNumberPagination):
    page_size = 20
    page_size_query_param = 'limit'
    max_page_size = 100

class PostViewSet(viewsets.ModelViewSet):
    """
    GET    /api/v1/posts/      -> list   (index)
    POST   /api/v1/posts/      -> create
    GET    /api/v1/posts/{id}/  -> retrieve (show)
    PUT    /api/v1/posts/{id}/  -> update
    DELETE /api/v1/posts/{id}/  -> destroy
    """
    queryset = Post.objects.published().select_related('author')
    serializer_class = PostSerializer
    pagination_class = PostPagination
    permission_classes = [permissions.IsAuthenticatedOrReadOnly]
    filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
    filterset_fields = ['published', 'author']
    search_fields = ['title', 'body']
    ordering_fields = ['created_at', 'title']

    def perform_create(self, serializer):
        serializer.save(author=self.request.user)

Django Admin: Auto-generated admin panel (no Rails equivalent)

# Input:  Admin user navigates to /admin/
# Output: Full CRUD interface for all models with search, filters, inline editing

# posts/admin.py
from django.contrib import admin
from .models import Post

@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
    """
    Django admin replaces Rails Admin gems (Administrate, ActiveAdmin).
    Single file gives full CRUD interface with zero frontend code.
    """
    list_display = ['title', 'author', 'published', 'created_at']
    list_filter = ['published', 'created_at', 'author']
    search_fields = ['title', 'body']
    prepopulated_fields = {'slug': ('title',)}
    date_hierarchy = 'created_at'
    list_editable = ['published']
    raw_id_fields = ['author']

    def get_queryset(self, request):
        return super().get_queryset(request).select_related('author')

ActiveRecord to Django ORM: Query pattern migration

# RAILS: Common ActiveRecord query patterns

# Basic CRUD
post = Post.create!(title: 'Hello', body: 'World', author: current_user)
post = Post.find(1)
post = Post.find_by(slug: 'hello')
post.update!(title: 'Updated')
post.destroy

# Scopes and chaining
Post.published.recent.where(author: current_user)
Post.where('created_at > ?', 1.week.ago).order(title: :asc)
Post.includes(:author, :comments).where(published: true)

# Aggregations
Post.count
Post.where(published: true).average(:views)
Post.group(:author_id).count
# DJANGO: Equivalent Django ORM patterns

from django.utils import timezone
from datetime import timedelta
from django.db.models import Avg, Count

# Basic CRUD
post = Post.objects.create(title='Hello', body='World', author=request.user)
post = Post.objects.get(id=1)
post = Post.objects.get(slug='hello')
post.title = 'Updated'
post.save()  # or Post.objects.filter(id=1).update(title='Updated')
post.delete()

# QuerySet chaining (replaces scopes)
Post.objects.published().recent().filter(author=request.user)
Post.objects.filter(created_at__gt=timezone.now() - timedelta(weeks=1)).order_by('title')
Post.objects.select_related('author').prefetch_related('comments').filter(published=True)

# Aggregations
Post.objects.count()
Post.objects.filter(published=True).aggregate(avg_views=Avg('views'))
Post.objects.values('author_id').annotate(post_count=Count('id'))

Django 6.0 Template Partials (replaces Rails partials pattern)

# RAILS partial: app/views/posts/_card.html.erb
# <%= render partial: 'card', locals: { post: @post } %>

# DJANGO 6.0: Template partials -- new in 6.0
# templates/posts/post_list.html

{% partialdef post_card %}
  <div class="card">
    <h3>{{ post.title }}</h3>
    <p>{{ post.body|truncatewords:30 }}</p>
    <span>By {{ post.author.get_full_name }}</span>
  </div>
{% endpartialdef %}

{% for post in posts %}
  {% partial post_card %}
{% endfor %}

# Can also be loaded from other templates:
# {% partial "posts/post_list#post_card" %}

Anti-Patterns

Wrong: Using Rails-style implicit routing in Django

# BAD -- expecting Django to auto-generate routes like Rails resources
# Django has NO magic routing. Every URL must be explicitly defined.

# This does NOT work in Django:
# resources :posts  <-- Rails syntax, not Django

# Developers from Rails often expect automatic URL generation
# and are surprised when /posts/new, /posts/:id/edit don't exist

Correct: Explicit URL configuration in Django

# GOOD -- Django requires explicit URL patterns for every route
# "Explicit is better than implicit" (Zen of Python)

from django.urls import path
from . import views

urlpatterns = [
    path('', views.PostListView.as_view(), name='post-list'),
    path('new/', views.PostCreateView.as_view(), name='post-create'),
    path('<slug:slug>/', views.PostDetailView.as_view(), name='post-detail'),
    path('<slug:slug>/edit/', views.PostUpdateView.as_view(), name='post-update'),
    path('<slug:slug>/delete/', views.PostDeleteView.as_view(), name='post-delete'),
]

# Or for API: use DRF Router (closest to Rails resources)
from rest_framework.routers import DefaultRouter
router = DefaultRouter()
router.register(r'posts', PostViewSet)  # generates all CRUD URLs

Wrong: Generating empty migration files like Rails

# BAD -- trying to create migration files manually like Rails
# Rails workflow: rails generate migration AddSlugToPosts slug:string
# Then you edit the empty migration file to add the column.

# In Django, you do NOT run a generator to create empty migrations.
# The migration is auto-generated from model changes.

Correct: Let Django auto-detect model changes

# GOOD -- Django's migration workflow: edit model, then makemigrations

# Step 1: Edit models.py -- add the field
class Post(models.Model):
    title = models.CharField(max_length=255)
    slug = models.SlugField(unique=True, max_length=255)  # add this

# Step 2: Auto-generate migration
# python manage.py makemigrations
# Creates: posts/migrations/0002_post_slug.py (auto-generated)

# Step 3: Apply
# python manage.py migrate

Wrong: Putting business logic in models like ActiveRecord

# BAD -- fat models with business logic (Rails ActiveRecord pattern)
class Post(models.Model):
    title = models.CharField(max_length=255)

    def publish_and_notify(self):
        """Rails developers tend to put everything in the model."""
        self.published = True
        self.save()
        send_mail('New Post', f'{self.title} published', '[email protected]', self.subscribers())
        cache.delete(f'post_list_{self.author_id}')
        analytics.track('post_published', self.id)

Correct: Use Django service layer or view logic

# GOOD -- keep models thin, put orchestration in views or services
# posts/services.py
from django.core.mail import send_mail
from django.core.cache import cache

def publish_post(post):
    """Orchestration logic lives outside the model."""
    post.published = True
    post.save(update_fields=['published', 'updated_at'])
    send_mail('New Post', f'{post.title} published', '[email protected]',
              [s.email for s in post.subscribers()])
    cache.delete(f'post_list_{post.author_id}')

Wrong: Using filter().first() assuming it raises like Rails find

# BAD -- Rails find raises RecordNotFound; Django filter().first() returns None
post = Post.objects.filter(id=99).first()
print(post.title)  # AttributeError: 'NoneType' has no attribute 'title'

Correct: Use get() with exception handling or get_object_or_404

# GOOD -- Django's get() raises DoesNotExist, or use the shortcut
from django.shortcuts import get_object_or_404

# Option A: Explicit exception handling
try:
    post = Post.objects.get(id=99)
except Post.DoesNotExist:
    raise Http404("Post not found")

# Option B: Shortcut (most common in Django views)
post = get_object_or_404(Post, id=99)

Common Pitfalls

Diagnostic Commands

# Verify Python and Django versions
python --version && python -c "import django; print(django.VERSION)"

# Check project configuration for errors
python manage.py check --deploy

# Show all registered URL patterns (replaces rails routes)
python manage.py show_urls  # requires django-extensions

# List all pending migrations
python manage.py showmigrations --plan | grep "\[ \]"

# Verify database connection and table existence
python manage.py dbshell -c "\dt"  # PostgreSQL: list tables

# Compare Rails and Django API responses
diff <(curl -s http://localhost:3000/api/v1/posts | jq .) \
     <(curl -s http://localhost:8000/api/v1/posts/ | jq .)

# Run Django tests with coverage
coverage run manage.py test && coverage report

# Check for common Django security issues
python manage.py check --deploy --tag security

# Inspect generated SQL for a queryset (debug ORM)
python manage.py shell -c "from posts.models import Post; print(Post.objects.published().query)"

# Check Django 6.0 CSP configuration
python manage.py check --tag csp

Version History & Compatibility

TechnologyVersionStatusNotes
Django6.0Current (Dec 2025)Template partials, background tasks, CSP, modernized email. Requires Python 3.12+ [src1]
Django5.2LTS until Apr 2028Composite primary keys, auto model imports in shell. Python 3.10-3.14 [src2]
Django5.1Supported (bug fixes)Async ORM improvements, connection pooling for PostgreSQL
Python3.13CurrentFree-threaded mode (experimental), improved REPL
Python3.12Minimum for Django 6.05% faster than 3.11, improved error messages
Python3.10Minimum for Django 5.2 LTSPattern matching, structural typing
DRF3.15.xCurrentCompatible with Django 4.2-6.0
Celery5.6.xCurrent (Jan 2026)Django integration built-in, no separate package needed
Rails (source)8.xCurrentSolid Queue, Solid Cache, Solid Cable, Kamal deploy [src3]
Rails (source)7.xSupportedHotwire, Turbo -- migrate WebSocket parts last
Rails (source)6.xEOLWebpacker -- good time to migrate away

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Python ecosystem is needed (ML, data science, AI) [src6]Team is purely Ruby/Rails expert with no Python needUpgrade Rails version instead
Django's built-in admin saves significant dev timeApp is primarily real-time WebSockets (ActionCable-heavy)Consider Node.js or stay on Rails with ActionCable
API-only backend with Django REST FrameworkSmall stable Rails 8 app with Solid Queue/Cache working well [src3]Keep Rails -- migration cost exceeds benefit
Team already knows Python or is willing to learnRails app has 100+ models with deep ActiveRecord patternsMigrate incrementally or refactor within Rails
Need Django 6.0 background tasks + async viewsStrong dependency on Rails gems with no Python equivalent (Hotwire, Turbo)Evaluate gem-by-gem; may require custom code
Company standardizing on Python across services [src6]Prototype/MVP stage where speed matters mostRails is faster for initial scaffolding [src8]
Want built-in CSP support and security hardening (Django 6.0) [src1]App uses Rails 8 Solid Stack + Kamal with no issuesEvaluate if migration justifies re-platforming cost

Important Caveats

Related Units