How to Migrate a Ruby on Rails App to Python Django
How do I migrate a Ruby on Rails app to Python Django?
TL;DR
- Bottom line: Migrate incrementally using the strangler fig pattern by mapping Rails MVC concepts to Django's MVT architecture. Both frameworks share similar philosophies (convention-over-configuration, batteries-included), making the transition smoother than most framework migrations. The biggest difference: Django models are the single source of truth for schema + validation, while Rails splits these across model files and separate migration files. [src4, src5]
- Key tool/command:
django-admin startproject myproject && python manage.py startapp myappto scaffold, thenpython manage.py inspectdb > models_generated.pyto reverse-engineer existing Rails DB tables into Django models. - Watch out for: Assuming Rails conventions exist in Django -- Django requires explicit URL configuration (no magic routing), explicit template directory setup, and models define the database schema directly. Django 6.0 now includes a built-in background tasks framework, potentially replacing both Sidekiq and Celery for simpler workloads.
- Works with: Python 3.12-3.14 (Django 6.0, latest 6.0.5 May 2026) or Python 3.10+ (Django 5.2 LTS), Ruby on Rails 7.2-8.1 as source (latest 8.1.3), PostgreSQL / MySQL / SQLite.
Constraints
- ActiveRecord and Django ORM both implement the Active Record pattern but define schema differently: Django models ARE the schema (single source of truth for both structure and validation), while Rails separates model files from migration files. Never assume a 1:1 file mapping. [src4, src5]
- Django requires explicit URL routing via
urls.pywithpath()orre_path(). There is no equivalent to Rails'resources :poststhat auto-generates 7 RESTful routes. Use DRF'sDefaultRouterfor the closest approximation. [src4] - Template engines are incompatible: ERB (
<%= %>/<% %>) vs Django Template Language ({{ }}/{% %}). No automated conversion tool exists; templates must be manually rewritten. [src8] - During parallel operation (strangler fig), only one framework must own database migrations. Never run
rails db:migrateandpython manage.py migrateconcurrently on the same database without explicit coordination. [src4, src5] - Django 6.0 requires Python 3.12 minimum (supports 3.12, 3.13, 3.14; latest patch is 6.0.5 from May 2026). If the team cannot upgrade to Python 3.12, use Django 5.2 LTS (supports Python 3.10-3.14, security updates until April 2028). [src1, src2, src9]
Quick Reference
| Rails Pattern | Django Equivalent | Example |
|---|---|---|
rails new myapp | django-admin startproject myproject | Creates project with settings.py, urls.py, wsgi.py, manage.py [src1] |
rails generate model User | Define class in models.py + python manage.py makemigrations | Django auto-detects model changes and generates migrations [src1, src5] |
rails generate scaffold Post | python manage.py startapp posts + manual model/view/template | Django 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:rollback | python manage.py migrate / migrate app 0003 | Auto-generates migration files from model changes [src1, src3] |
before_action / after_action | Middleware classes or decorators | @login_required decorator or custom Middleware.__call__() [src1, src4] |
| ERB / Haml templates | Django 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] |
ActionMailer | django.core.mail (built-in, modernized in 6.0) | send_mail('Subject', 'Body', '[email protected]', ['[email protected]']) [src1] |
RSpec / Minitest | pytest-django / django.test.TestCase | class PostTestCase(TestCase): def test_create(self): ... [src1, src4] |
Pundit / CanCanCan | django.contrib.auth.permissions + django-guardian | @permission_required('posts.add_post') or object-level perms [src1] |
ActiveSupport::Concern | Python mixins / abstract models | class TimestampMixin(models.Model): class Meta: abstract = True [src4, src5] |
Sprockets / Propshaft | django.contrib.staticfiles + Vite/Webpack | {% static 'css/style.css' %} or django-vite [src1, src8] |
Rails.credentials / dotenv | django-environ / python-decouple | env = 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.
Decision Logic
Agent-facing if/then rules for recommending a concrete migration path. Each rule is independent; evaluate top-down and stop at the first match.
If the team has no Python ecosystem need and the Rails 8.1 app runs well on Solid Queue/Cache + Kamal
→ Do not migrate. Rails 8.1 (latest 8.1.3, Mar 2026) eliminated the Redis/Memcached operational burden that historically pushed teams to Django; the migration cost rarely pays back without a strategic Python/ML driver. [src3, src6]
If the primary driver is Python ecosystem alignment (ML, data science, AI pipelines)
→ Migrate incrementally with the strangler fig pattern: stand up Django alongside Rails on the same database, route migrated endpoints through nginx, and cut over one resource at a time. Strong fit — Django sits natively next to NumPy, pandas, PyTorch. [src4, src6]
If the Rails app is small (< 20 models) and stable
→ Either keep Rails or do a clean rewrite in one cycle; do not build a coexistence shell for a small app — the reverse-proxy plumbing costs more than the rewrite. [src4, src5]
If the source is on Python < 3.12 or you need an LTS guarantee
→ Target Django 5.2 LTS (Python 3.10-3.14, security fixes through April 2028), not Django 6.0 — 6.0 dropped Python 3.10/3.11 and follows the 8-month feature cadence rather than LTS. [src1, src2, src9]
If background jobs are simple (emails, light processing) and you are on Django 6.0
→ Use the built-in django.tasks framework instead of adding Celery + Redis; reserve Celery 5.6 for complex retry/priority/scheduling pipelines that django.tasks does not yet cover. [src1, src9, src10]
If the app is API-only (no server-rendered HTML)
→ Migrate to Django REST Framework (closest analogue to Rails API mode) and use JWT (djangorestframework-simplejwt) for auth so sessions do not have to be shared across the two stacks during cutover. [src4]
If the app is real-time / WebSocket-heavy (ActionCable) or leans on Hotwire/Turbo
→ Reconsider the target. Migrate ActionCable parts last via Django Channels, or stay on Rails — Hotwire/Turbo have no first-class Django equivalent and force a custom build. [src3, src7]
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
- N+1 queries from missing select_related/prefetch_related: Rails
includes()has two Django equivalents:select_related()for ForeignKey/OneToOne andprefetch_related()for ManyToMany/reverse FK. Using the wrong one or forgetting both causes N+1 queries. Fix: usedjango-debug-toolbarand always addselect_relatedfor FK access. [src1, src5] - Forgetting on_delete on ForeignKey: Rails
dependent: :destroyis optional. Django requireson_deleteon every ForeignKey -- it refuses to create the migration without it. Fix: always specifyon_delete=models.CASCADE,SET_NULL, orPROTECT. [src1, src4] - Session format incompatibility during parallel operation: Rails stores sessions in encrypted cookies with a Rails-specific format. Django uses its own session backend. Users cannot share sessions. Fix: use JWT tokens for API auth during transition, or accept re-authentication. [src4, src5]
- Template syntax confusion: ERB uses
<%= %>for output and<% %>for logic. Django uses{{ }}for variables and{% %}for tags. Fix: test syntax interactively withpython -c "from django.template import Template, Context; ...". [src8] - Missing __str__ method on models: Rails models display nicely via
inspect. Django models display as<Post: Post object (1)>unless you define__str__. Fix: always adddef __str__(self): return self.title. [src5] - Assuming manage.py shell loads everything like rails console: Django shell does not auto-import models (before 5.2). Fix: Django 5.2+ auto-imports all models, or use
shell_plusfromdjango-extensionson older versions. [src2, src4] - Not using Django's built-in admin: Rails has no built-in admin (needs ActiveAdmin/Administrate). Django admin is production-ready out of the box. Fix: register models in
admin.py-- 5 lines per model. [src1, src7] - Confusing null=True with blank=True: Rails has one concept (
allow_nil). Django has two:null=True(DB allows NULL) andblank=True(form allows empty). For strings: useblank=Truewithoutnull=True. Fix:models.CharField(max_length=100, blank=True, default=''). [src1] - Using psycopg2-binary in production: The
psycopg2-binarypackage is fine for development but not recommended for production. Fix: usepsycopg[binary](psycopg3) for Django 5.2+/6.0. [src2]
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
| Technology | Version | Status | Notes |
|---|---|---|---|
| Django | 6.0 (latest 6.0.5, May 2026) | Current | Template partials, background tasks, CSP, modernized email. Supports Python 3.12-3.14 [src1, src9, src10] |
| Django | 5.2 | LTS, security fixes until Apr 2028 | Composite primary keys, auto model imports in shell. Python 3.10-3.14 [src2] |
| Django | 5.1 | EOL (unsupported since Dec 2, 2025) | Upgrade required. Async ORM improvements, connection pooling for PostgreSQL [src9] |
| Python | 3.14 | Supported (Django 6.0) | Latest CPython release |
| Python | 3.13 | Current | Free-threaded mode (experimental), improved REPL |
| Python | 3.12 | Minimum for Django 6.0 | 5% faster than 3.11, improved error messages |
| Python | 3.10 | Minimum for Django 5.2 LTS | Pattern matching, structural typing |
| DRF | 3.16.x | Current | Compatible with Django 4.2-6.0 |
| Celery | 5.6.x | Current (Jan 2026) | Django integration built-in, no separate package needed |
| Rails (source) | 8.1 (latest 8.1.3, Mar 2026) | Current | Solid Queue/Cache/Cable, Kamal deploy, active maintenance [src3] |
| Rails (source) | 8.0 | Bug fixes ended May 7, 2026 | Upgrade to 8.1 -- Solid stack baseline [src3] |
| Rails (source) | 7.2 | Supported | Hotwire, Turbo -- migrate WebSocket parts last |
| Rails (source) | 7.0 / 7.1 | EOL | No longer maintained -- good time to migrate away |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Python ecosystem is needed (ML, data science, AI) [src6] | Team is purely Ruby/Rails expert with no Python need | Upgrade Rails version instead |
| Django's built-in admin saves significant dev time | App is primarily real-time WebSockets (ActionCable-heavy) | Consider Node.js or stay on Rails with ActionCable |
| API-only backend with Django REST Framework | Small 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 learn | Rails app has 100+ models with deep ActiveRecord patterns | Migrate incrementally or refactor within Rails |
| Need Django 6.0 background tasks + async views | Strong 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 most | Rails 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 issues | Evaluate if migration justifies re-platforming cost |
Important Caveats
- Django and Rails both support bcrypt for password hashing, but the hash format differs. Django prepends an algorithm identifier (
bcrypt_sha256$). To reuse Rails Devise password hashes, add a custom hasher or useBCryptPasswordHasher(notBCryptSHA256PasswordHasher) and verify compatibility. [src4, src5] - Django's migration system and Rails' migration system are incompatible. During parallel operation, designate one framework as the migration authority. If Django is generating migrations, do not run
rails db:migratewithout coordination. [src1, src3] - Rails'
ActiveRecord::Base.transactionand Django'sdjango.db.transaction.atomic()both wrap database transactions, but Django'satomic()can be used as a context manager or decorator -- a pattern that does not exist in Rails. Nestedatomic()blocks use savepoints by default. [src1] - Django's URL trailing slash convention (
/posts/not/posts) differs from Rails' default (no trailing slash). This can cause 301 redirects during parallel operation. SetAPPEND_SLASH = Falsein Django settings if matching Rails URLs exactly. [src4] - Django REST Framework is a third-party package, not part of Django core. For API-only migrations, DRF is effectively required. Budget time for learning DRF's serializer, viewset, and permission patterns -- they differ from Rails' jbuilder or active_model_serializers approach. [src4]
- Django 6.0's built-in background tasks framework (
django.tasks) is new and may not yet cover all Sidekiq/Solid Queue use cases (complex retry strategies, rate limiting, priority queues). For production workloads requiring these features, Celery 5.6 remains the more mature option. [src1] - Rails 8 has significantly reduced the case for migration by eliminating Redis/Memcached dependencies via Solid Queue, Solid Cache, and Solid Cable. If the Rails 8 app is already deployed via Kamal and running well, the migration cost may no longer be justified unless Python ecosystem integration is the primary driver. [src3]