django-admin startproject myproject && python manage.py startapp myapp to scaffold, then python manage.py inspectdb > models_generated.py to reverse-engineer existing Rails DB tables into Django models.urls.py with path() or re_path(). There is no equivalent to Rails' resources :posts that auto-generates 7 RESTful routes. Use DRF's DefaultRouter for the closest approximation. [src4]<%= %> / <% %>) vs Django Template Language ({{ }} / {% %}). No automated conversion tool exists; templates must be manually rewritten. [src8]rails db:migrate and python manage.py migrate concurrently on the same database without explicit coordination. [src4, src5]| 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] |
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.
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.
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.
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).
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.
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.
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.
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.
# 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)
# 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')
# 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'))
# 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" %}
# 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
# 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
# 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.
# 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
# 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)
# 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}')
# 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'
# 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)
includes() has two Django equivalents: select_related() for ForeignKey/OneToOne and prefetch_related() for ManyToMany/reverse FK. Using the wrong one or forgetting both causes N+1 queries. Fix: use django-debug-toolbar and always add select_related for FK access. [src1, src5]dependent: :destroy is optional. Django requires on_delete on every ForeignKey -- it refuses to create the migration without it. Fix: always specify on_delete=models.CASCADE, SET_NULL, or PROTECT. [src1, src4]<%= %> for output and <% %> for logic. Django uses {{ }} for variables and {% %} for tags. Fix: test syntax interactively with python -c "from django.template import Template, Context; ...". [src8]inspect. Django models display as <Post: Post object (1)> unless you define __str__. Fix: always add def __str__(self): return self.title. [src5]shell_plus from django-extensions on older versions. [src2, src4]admin.py -- 5 lines per model. [src1, src7]allow_nil). Django has two: null=True (DB allows NULL) and blank=True (form allows empty). For strings: use blank=True without null=True. Fix: models.CharField(max_length=100, blank=True, default=''). [src1]psycopg2-binary package is fine for development but not recommended for production. Fix: use psycopg[binary] (psycopg3) for Django 5.2+/6.0. [src2]# 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
| Technology | Version | Status | Notes |
|---|---|---|---|
| Django | 6.0 | Current (Dec 2025) | Template partials, background tasks, CSP, modernized email. Requires Python 3.12+ [src1] |
| Django | 5.2 | LTS until Apr 2028 | Composite primary keys, auto model imports in shell. Python 3.10-3.14 [src2] |
| Django | 5.1 | Supported (bug fixes) | Async ORM improvements, connection pooling for PostgreSQL |
| 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.15.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.x | Current | Solid Queue, Solid Cache, Solid Cable, Kamal deploy [src3] |
| Rails (source) | 7.x | Supported | Hotwire, Turbo -- migrate WebSocket parts last |
| Rails (source) | 6.x | EOL | Webpacker -- good time to migrate away |
| 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 |
bcrypt_sha256$). To reuse Rails Devise password hashes, add a custom hasher or use BCryptPasswordHasher (not BCryptSHA256PasswordHasher) and verify compatibility. [src4, src5]rails db:migrate without coordination. [src1, src3]ActiveRecord::Base.transaction and Django's django.db.transaction.atomic() both wrap database transactions, but Django's atomic() can be used as a context manager or decorator -- a pattern that does not exist in Rails. Nested atomic() blocks use savepoints by default. [src1]/posts/ not /posts) differs from Rails' default (no trailing slash). This can cause 301 redirects during parallel operation. Set APPEND_SLASH = False in Django settings if matching Rails URLs exactly. [src4]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]