---
# === IDENTITY ===
id: software/migrations/rails-to-django/2026
canonical_question: "How do I migrate a Ruby on Rails app to Python Django?"
aliases:
  - "Rails to Django migration guide"
  - "convert Rails app to Django"
  - "Ruby on Rails to Python Django"
  - "replace Rails with Django"
  - "ActiveRecord to Django ORM migration"
  - "Rails developer learning Django"
  - "switch from Ruby on Rails to Django"
entity_type: software_reference
domain: software > migrations > rails_to_django
region: global
jurisdiction: global
temporal_scope: 2020-2026

# === VERIFICATION ===
last_verified: 2026-05-29
confidence: 0.88
version: 2.1
first_published: 2026-02-17

# === TEMPORAL VALIDITY ===
temporal_validity:
  status: evolving
  last_breaking_change: "Django 6.0 (Dec 2025) — dropped Python <3.12, added background tasks framework"
  next_review: 2026-11-25
  change_sensitivity: medium

# === CONSTRAINTS ===
constraints:
  - "ActiveRecord and Django ORM both use Active Record pattern but define schema differently: Django models ARE the schema (single source of truth), Rails splits models and migration files"
  - "Django requires explicit URL routing — no magic resourceful routes like Rails' resources :posts"
  - "Template engines are incompatible: ERB (<%= %>) vs Django Template Language ({{ }} / {% %}), no automated conversion tool exists"
  - "During parallel operation only one framework must own database migrations — never run rails db:migrate and python manage.py migrate concurrently on the same database"
  - "Django 6.0 (latest 6.0.5, May 2026) requires Python 3.12-3.14; Django 5.2 LTS supports Python 3.10-3.14 (security fixes through April 2028)"

# === SKIP CONDITIONS ===
skip_this_unit_if:
  - condition: "Migrating Rails to a Node.js/TypeScript backend"
    use_instead: "software/migrations/rails-to-nodejs/2026"
  - condition: "Migrating a PHP application to Python"
    use_instead: "software/migrations/php-to-python/2026"
  - condition: "Upgrading between Rails versions (e.g. Rails 7 to Rails 8)"
    use_instead: "Rails upgrade guides at guides.rubyonrails.org"

# === AGENT HINTS ===
inputs_needed:
  - key: rails_version
    question: "Which version of Rails is the source application running?"
    type: choice
    options: ["Rails 6.x", "Rails 7.x", "Rails 8.x"]
  - key: app_size
    question: "How many models does the Rails application have?"
    type: choice
    options: ["Small (<20 models)", "Medium (20-80 models)", "Large (80+ models)"]
  - key: python_experience
    question: "What is the team's Python experience level?"
    type: choice
    options: ["No Python experience", "Some Python experience", "Proficient in Python"]

# === DISTRIBUTION ===
canonical_source: "https://knowledgelib.io/software/migrations/rails-to-django/2026"
suggested_citation: "Source: knowledgelib.io — AI Knowledge Library (verified 2026-05-29)"

# === RELATED UNITS ===
related_kos:
  related_to:
    - id: "software/migrations/rails-to-nodejs/2026"
      label: "Rails to Node.js Migration"
    - id: "software/migrations/php-to-python/2026"
      label: "PHP to Python Migration"
  alternative_to:
    - id: "software/migrations/rails-to-nodejs/2026"
      label: "Rails to Node.js (alternative target framework)"
  often_confused_with:
    - id: "software/migrations/express-to-nestjs/2026"
      label: "Express to NestJS (Node.js ecosystem, not Rails)"

# === SOURCES (10 authoritative sources) ===
# Types: official_docs, technical_blog, rfc_spec, academic_paper, community_resource, industry_report
sources:
  - id: src1
    title: "Django 6.0 Release Notes"
    author: Django Software Foundation
    url: https://docs.djangoproject.com/en/6.0/releases/6.0/
    type: official_docs
    published: 2025-12-03
    reliability: authoritative
  - id: src2
    title: "Django 5.2 LTS Release Notes"
    author: Django Software Foundation
    url: https://docs.djangoproject.com/en/6.0/releases/5.2/
    type: official_docs
    published: 2025-04-02
    reliability: authoritative
  - id: src3
    title: "Ruby on Rails 8.0 Release Notes"
    author: Rails Core Team
    url: https://guides.rubyonrails.org/8_0_release_notes.html
    type: official_docs
    published: 2024-11-07
    reliability: authoritative
  - id: src4
    title: "Transitioning From Ruby on Rails to Django"
    author: Simple Thread
    url: https://www.simplethread.com/transitioning-from-ruby-on-rails-to-django/
    type: technical_blog
    published: 2024-06-10
    reliability: high
  - id: src5
    title: "9 Technical Notes for Migrating from Rails to Django"
    author: Mahrad Ataeefard
    url: https://m3rad.medium.com/9-technical-notes-for-migrating-from-rails-to-django-fdc21bc00e86
    type: technical_blog
    published: 2023-08-22
    reliability: high
  - id: src6
    title: "Django vs Ruby on Rails in 2026: A Practical Comparison"
    author: TheLinuxCode
    url: https://thelinuxcode.com/django-vs-ruby-on-rails-in-2026-a-practical-engineer-to-engineer-comparison/
    type: industry_report
    published: 2026-01-15
    reliability: moderate_high
  - id: src7
    title: "Django vs Ruby on Rails -- A Head-to-Head Comparison"
    author: Better Stack Community
    url: https://betterstack.com/community/guides/scaling-python/django-vs-ruby-on-rails/
    type: technical_blog
    published: 2024-11-20
    reliability: high
  - id: src8
    title: "Django vs Ruby on Rails: Which Framework is Better?"
    author: Django Stars
    url: https://djangostars.com/blog/django-or-rails/
    type: technical_blog
    published: 2024-09-15
    reliability: moderate_high
  - id: src9
    title: "Django 6.0 released"
    author: Django Software Foundation
    url: https://www.djangoproject.com/weblog/2025/dec/03/django-60-released/
    type: official_docs
    published: 2025-12-03
    reliability: authoritative
  - id: src10
    title: "Django Releases Version 6.0 with Built-In Background Tasks and Native CSP Support"
    author: InfoQ
    url: https://www.infoq.com/news/2026/01/django-6-release/
    type: industry_report
    published: 2026-01-20
    reliability: high
---

# 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 myapp` to scaffold, then `python manage.py inspectdb > models_generated.py` to reverse-engineer existing Rails DB tables into Django models. [src1]
- **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 (no separate migration generator needed). Django 6.0 now includes a built-in background tasks framework, potentially replacing both Sidekiq and Celery for simpler workloads. [src1, src4, src9, src10]
- **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. [src1, src3, src9]

## 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.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]
- 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:migrate` and `python manage.py migrate` concurrently 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 command; 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` / `python manage.py migrate app 0003` | Django 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', 'from@example.com', ['to@example.com'])` [src1] |
| `RSpec` / `Minitest` | `pytest-django` / `django.test.TestCase` | `class PostTestCase(TestCase): def test_create(self): ...` [src1, src4] |
| `Pundit` / `CanCanCan` (authorization) | `django.contrib.auth.permissions` + `django-guardian` | `@permission_required('posts.add_post')` or object-level perms [src1] |
| `ActiveSupport::Concern` (mixins) | Python mixins / abstract models | `class TimestampMixin(models.Model): class Meta: abstract = True` [src4, src5] |
| `Sprockets` / `Propshaft` (assets) | `django.contrib.staticfiles` + Vite/Webpack | `{% static 'css/style.css' %}` or Vite integration via `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] |
| `{% partialdef %}` (Django 6.0) | Template Partials (new in Django 6.0) | `{% partialdef card %}...{% endpartialdef %}` — replaces Rails partials pattern [src1] |

## 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]

```bash
# 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, giving you a starting point without manually rewriting every model. [src1, src5]

```bash
# Create virtual environment and install Django
python -m venv venv
source venv/bin/activate  # or venv\Scripts\activate on Windows
pip install django psycopg[binary] django-environ

# Create Django project
django-admin startproject myproject .

# Configure database in settings.py to point at Rails database
# DATABASES = {
#     'default': {
#         'ENGINE': 'django.db.backends.postgresql',
#         'NAME': 'myapp_development',
#         'USER': 'postgres',
#         'PASSWORD': 'password',
#         'HOST': 'localhost',
#         'PORT': '5432',
#     }
# }

# 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]

```python
# 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):
    # Fields (replaces Rails migration + model validations)
    title = models.CharField(max_length=255)  # validates :title, length: { maximum: 255 }
    slug = models.SlugField(unique=True, max_length=255)
    body = models.TextField()  # validates :body, presence: true
    published = models.BooleanField(default=False)
    author = models.ForeignKey(
        'users.User',
        on_delete=models.CASCADE,  # dependent: :destroy equivalent
        related_name='posts',      # replaces has_many :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). `python manage.py shell -c "from posts.models import Post; print(Post.objects.published().count())"` returns expected count.

### 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]

```python
# 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


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


# Replaces PostsController#show
class PostDetailView(DetailView):
    model = Post
    template_name = 'posts/post_detail.html'
    context_object_name = 'post'


# Replaces PostsController#create (with before_action :authenticate_user!)
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('<slug:slug>/', views.PostDetailView.as_view(), name='post-detail'),
    path('new/', views.PostCreateView.as_view(), name='post-create'),
]

# myproject/urls.py -- root URL configuration
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('posts/', include('posts.urls')),
]
```

**Verify**: `python manage.py runserver` and visit `http://localhost:8000/posts/` to see the post list. `curl http://localhost:8000/posts/` returns HTML.

### 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 (replaces both Sidekiq and Celery for basic use cases). 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]

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

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

# posts/tasks.py -- replaces Sidekiq/Solid Queue worker
from django.tasks import task
from django.core.mail import send_mail


@task
def send_welcome_email(user_id):
    """Replaces WelcomeMailer.welcome(user).deliver_later"""
    from users.models import User
    user = User.objects.get(id=user_id)
    send_mail(
        subject='Welcome!',
        message=f'Hello {user.first_name}, welcome to our platform!',
        from_email='noreply@example.com',
        recipient_list=[user.email],
    )

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


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

# pip install celery[redis] django-celery-results

# myproject/celery.py -- replaces Sidekiq configuration
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()

# settings.py additions
CELERY_BROKER_URL = 'redis://localhost:6379/0'
CELERY_RESULT_BACKEND = 'django-db'

# posts/tasks.py -- replaces Sidekiq worker
from celery import shared_task

@shared_task(bind=True, max_retries=3)
def send_welcome_email(self, user_id):
    from users.models import User
    user = User.objects.get(id=user_id)
    send_mail(
        subject='Welcome!',
        message=f'Hello {user.first_name}!',
        from_email='noreply@example.com',
        recipient_list=[user.email],
    )

# 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 the 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. This is the strangler fig pattern. [src4]

```nginx
# /etc/nginx/conf.d/myapp.conf
upstream rails_app {
    server 127.0.0.1:3000;
}

upstream django_app {
    server 127.0.0.1:8000;
}

server {
    listen 80;
    server_name myapp.com;

    # Migrated endpoints -> Django
    location /posts {
        proxy_pass http://django_app;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

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

    # Everything else -> Rails (shrinks over time)
    location / {
        proxy_pass http://rails_app;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}
```

**Verify**: `curl -I http://myapp.com/posts/` returns Django response headers; `curl -I http://myapp.com/legacy/` returns 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 -- no gem equivalent needed. For bcrypt hash compatibility with Devise, configure Django's `PASSWORD_HASHERS`. [src1, src7]

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


def login_view(request):
    """Replaces Devise SessionsController#create."""
    if request.method == 'POST':
        email = request.POST['email']
        password = request.POST['password']
        # Django's auth supports bcrypt -- same hashes as Devise
        # pip install bcrypt (or add to HASHERS)
        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 (compatibility with Devise bcrypt hashes)
PASSWORD_HASHERS = [
    'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
    'django.contrib.auth.hashers.BCryptPasswordHasher',
    'django.contrib.auth.hashers.PBKDF2PasswordHasher',
]

# For API-only migration, use Django REST Framework + JWT:
# pip install djangorestframework djangorestframework-simplejwt
# REST_FRAMEWORK = {
#     'DEFAULT_AUTHENTICATION_CLASSES': [
#         'rest_framework_simplejwt.authentication.JWTAuthentication',
#     ],
# }
```

**Verify**: Login with existing Rails/Devise credentials works in Django. `python manage.py shell -c "from django.contrib.auth import authenticate; print(authenticate(username='test@example.com', password='password'))"` returns the user object.

## Code Examples

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

```python
# 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)

```python
# 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).
    This single file gives you a 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',)}  # auto-fills slug from title
    date_hierarchy = 'created_at'
    list_editable = ['published']  # inline toggle on list view
    raw_id_fields = ['author']    # efficient FK picker for large user tables

    def get_queryset(self, request):
        """Optimize admin queries with select_related."""
        return super().get_queryset(request).select_related('author')
```

### ActiveRecord to Django ORM: Query pattern migration

```ruby
# 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
```

```python
# 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)

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

# DJANGO 6.0: Template partials -- new in 6.0, replaces include pattern
# 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

```python
# 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 coming 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

```python
# GOOD -- Django requires explicit URL patterns for every route
# This is by design: "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

```python
# 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

```python
# 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
# Django detects the new field and creates:
# posts/migrations/0002_post_slug.py (auto-generated, no manual editing needed)

# Step 3: Apply
# python manage.py migrate
```

### Wrong: Putting business logic in models like ActiveRecord

```python
# 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', 'from@ex.com', self.subscribers())
        cache.delete(f'post_list_{self.author_id}')
        analytics.track('post_published', self.id)
```

### Correct: Use Django service layer or view logic

```python
# 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'])

    # Side effects are explicit and testable
    send_mail('New Post', f'{post.title} published', 'from@ex.com',
              [s.email for s in post.subscribers()])
    cache.delete(f'post_list_{post.author_id}')

# posts/views.py
from .services import publish_post

class PostPublishView(LoginRequiredMixin, View):
    def post(self, request, pk):
        post = get_object_or_404(Post, pk=pk, author=request.user)
        publish_post(post)
        return redirect('post-detail', slug=post.slug)
```

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

```python
# 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`

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

# Option A: Explicit exception handling (like Rails find + rescue)
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 (SQL JOIN) and `prefetch_related()` for ManyToMany/reverse FK (separate query). Using the wrong one or forgetting both causes N+1 queries. Fix: use `django-debug-toolbar` in development and always add `select_related` for FK access in templates and serializers. [src1, src5]
- **Forgetting `on_delete` on ForeignKey**: Rails `dependent: :destroy` is optional. Django requires `on_delete` on every `ForeignKey` -- it will refuse to create the migration without it. Fix: always specify `on_delete=models.CASCADE` (destroy), `models.SET_NULL` (nullify), or `models.PROTECT` (prevent). [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 (DB, cache, or signed cookies). Users cannot share sessions between both apps. Fix: use JWT tokens for API auth during transition, or accept that users must re-authenticate when routed to Django. [src4, src5]
- **Template syntax confusion**: ERB uses `<%= %>` for output and `<% %>` for logic. Django uses `{{ }}` for variables and `{% %}` for tags. The most common mistake is using `{{ }}` for logic (e.g., `{{ if condition }}` instead of `{% if condition %}`). Fix: run `python -c "from django.template import Template; Template('{% if x %}y{% endif %}').render(Context({'x': True}))"` to test syntax interactively. [src8]
- **Missing `__str__` method on models**: Rails models display nicely in console via `inspect`. Django models display as `<Post: Post object (1)>` unless you define `__str__`. Fix: always add `def __str__(self): return self.title` to every model. [src5]
- **Assuming `manage.py shell` loads everything like `rails console`**: Django shell does not auto-import models. Fix: Django 5.2+ auto-imports all models in `manage.py shell` (via `--auto-import`), or use `python manage.py shell_plus` from `django-extensions` on older versions. [src2, src4]
- **Not using Django's built-in admin**: Rails has no built-in admin (you need gems like ActiveAdmin or Administrate). Django's admin is production-ready out of the box. Skipping it and building custom CRUD views for internal tools is wasted effort. Fix: register models in `admin.py` -- it takes 5 lines per model and gives you search, filtering, pagination, and inline editing. [src1, src7]
- **Confusing `null=True` with `blank=True`**: Rails `validates :field, allow_nil: true` has one concept. Django has two: `null=True` (database allows NULL) and `blank=True` (form validation allows empty). For string fields, use `blank=True` without `null=True` (Django convention: use empty string, not NULL, for blank text). Fix: `models.CharField(max_length=100, blank=True, default='')`. [src1]
- **Using `psycopg2-binary` in production**: The `psycopg2-binary` package is fine for development but not recommended for production. In production, install `psycopg[binary]` (psycopg3) or compile `psycopg2` from source. Django 5.2+ officially supports psycopg3. Fix: `pip install psycopg[binary]` for Django 5.2+/6.0. [src2]

## Diagnostic Commands

```bash
# 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
# or: python manage.py shell -c "from django.urls import get_resolver; [print(p.pattern, p.name) for p in get_resolver().url_patterns]"

# 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 | Current (latest 6.0.5, May 2026) | Latest release | 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 | Recommended for production | Composite primary keys, auto model imports in shell. Supports 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 | Current | Supported (Django 6.0) | Latest CPython release |
| Python 3.13 | Current | Recommended | Free-threaded mode (experimental), improved REPL |
| Python 3.12 | Minimum for Django 6.0 | Stable | 5% faster than 3.11, improved error messages |
| Python 3.10 | Minimum for Django 5.2 LTS | Maintenance | Pattern matching, structural typing |
| Django REST Framework | 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 8.1 (source) | Current (latest 8.1.3, Mar 2026) | Solid Queue/Cache/Cable, Kamal deploy, active maintenance [src3] |
| Rails 8.0 (source) | Bug fixes ended May 7, 2026 | Upgrade to 8.1 — Solid stack baseline [src3] |
| Rails 7.2 (source) | Supported | Hotwire, Turbo -- migrate WebSocket parts last |
| Rails 7.0 / 7.1 (source) | 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 integrations) [src6] | Team is purely Ruby/Rails expert with no Python need | Upgrade Rails version instead |
| Django's built-in admin saves significant dev time for internal tools | 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 no pain points and 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 and custom SQL | Migrate incrementally or refactor within Rails |
| Need Django 6.0 background tasks + async views in same project | Strong dependency on specific Rails gems with no Python equivalent (e.g., Turbo, Hotwire) | Evaluate gem-by-gem; may require custom code |
| Company standardizing on Python across backend + data science 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 and deploy via 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 slightly. Django prepends an algorithm identifier (`bcrypt_sha256$`). To reuse Rails Devise password hashes, add a custom password hasher that strips the prefix, or use `BCryptPasswordHasher` (not `BCryptSHA256PasswordHasher`) and manually 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 for the shared database, do not run `rails db:migrate` without coordination. [src1, src3]
- Rails' `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]
- Django's URL trailing slash convention (`/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 URL patterns exactly, or configure nginx to handle the difference. [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 significantly 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 (e.g., 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]

## Related Units

- [Rails to Node.js Migration](/software/migrations/rails-to-nodejs/2026)
- [PHP to Python Migration](/software/migrations/php-to-python/2026)
- [JavaScript to TypeScript Migration](/software/migrations/javascript-to-typescript/2026)
- [Express to NestJS Migration](/software/migrations/express-to-nestjs/2026)
