Ruby on Rails vs Django
Both frameworks do the same job and the comparison tables that rank them are written by people who have one of them installed. So this page has both. Rails 8.1.3.1 and Django 6.1.1 sit in two directories on one laptop, pointed at the same PostgreSQL, holding the same two tables and the same 500 rows, and every number and error string below came out of that pair this morning.
Conditions, because a number without them is not checkable. Apple M2 Max, 12 cores, macOS
arm64-darwin25. Ruby 4.0.5 (2026-05-20 revision 64336ffd0e) with PRISM, built by RVM with no YJIT.
Rails 8.1.3.1 on Puma 8.0.2. Python 3.14.7, with sys._jit.is_available() False and
sysconfig.get_config_var('Py_GIL_DISABLED') 0, so neither the JIT nor the free-threaded build is in
play. Django 6.1.1 on gunicorn 26.2.0 with psycopg 3.3.6. PostgreSQL 17.7 on port 15432, loopback
only. Both applications serve /posts: 50 posts with their author's name, joined or preloaded, as
JSON. Rails runs RAILS_ENV=production, Django runs DEBUG = False.
The two models are the same shape on both sides:
class Author(models.Model):
name = models.CharField(max_length=120)
email = models.EmailField(unique=True)
class Post(models.Model):
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="posts")
title = models.CharField(max_length=200)
body = models.TextField()
published_at = models.DateTimeField(null=True, blank=True)
class Post < ApplicationRecord
belongs_to :author
end
That is not a rhetorical trick. The Rails file is four lines because the columns are not in it, and the difference between those two listings is the largest thing on this page.
Where the schema lives is the decision everything else follows from
Django's model class is the schema. manage.py makemigrations diffs the model classes against the
migration files already on disk and writes the difference. Rails' database is the schema.
bin/rails generate model writes a migration, db:migrate runs it against PostgreSQL, and the model
class learns its attributes by asking the table at boot.
The consequence is visible in about thirty seconds. Both tables were altered by hand, outside the framework, the way a DBA or a psql session or another service would do it:
$ psql -h 127.0.0.1 -p 15432 -d rvd_django -c 'ALTER TABLE blog_post DROP COLUMN body;'
ALTER TABLE
$ python manage.py makemigrations blog
No changes detected in app 'blog'
makemigrations is not looking at the database. Nothing in Django noticed, and the notice arrived at
query time instead:
django.db.utils.ProgrammingError: column blog_post.body does not exist
LINE 1: ...d", "blog_post"."author_id", "blog_post"."title", "blog_post...
^
The same two statements against the Rails table, ADD COLUMN slug varchar(200) and
DROP COLUMN body, with no code change and no generator run:
$ bin/rails runner 'p Post.column_names; p Post.first.title; p Post.first.slug; p Post.first.respond_to?(:body)'
["id", "author_id", "title", "published_at", "created_at", "updated_at", "slug"]
"Post 0"
nil
false
Active Record picked up slug as a real attribute, dropped body off the model, and kept answering.
Django has the reverse path too, and it is manage.py inspectdb, which reads the tables and prints
model classes with managed = False and a header telling you to rearrange them by hand. Useful once,
when you adopt a database. Not a thing you run on every deploy.
Neither direction is free. Django's costs you drift: the model and the table can disagree silently,
and the only thing that reconciles them is the migration history, which is a third artifact that also
has to be right. Rails' costs you a second source of truth in db/schema.rb, a file that is
generated, checked in, and conflicts in every merge where two branches added a column.
What each one does when you add a NOT NULL column to a populated table
Adding slug = models.SlugField(max_length=200) to Post, with 500 rows in the table, Django stops
before it writes anything:
$ python manage.py makemigrations blog --noinput
Field 'slug' on model 'post' not migrated: it is impossible to add a non-nullable field without specifying a default.
$ echo $?
3
Exit 3, not 1. The line is sys.exit(3) at django/db/migrations/questioner.py:329, in
NonInteractiveMigrationQuestioner#ask_not_null_addition, under the comment "We can't ask the user,
so act like the user aborted". Interactively it prompts instead, with "Please select a fix: 1)
Provide a one-off default now", which is why makemigrations in a CI job with no tty is a hang or a
crash rather than a build step.
Rails does not ask. The generator writes what you asked for, db:migrate runs it, and the database
is the thing that says no:
== 20260927084905 AddSlugToPosts: migrating ===================================
-- add_column(:posts, :slug, :string, {null: false})
ActiveRecord::NotNullViolation: PG::NotNullViolation: ERROR: column "slug" of relation "posts" contains null values
The migration was canceled and the transaction rolled back, so \d posts afterwards was unchanged.
On this table, with 500 rows in it, both frameworks protected you. On an empty development table
Rails would have succeeded, committed, and put the failure in the production deploy instead. Django
would have refused in both places, because it never consults the rows at all. That is the honest
version of the trade: Django's check is static and therefore consistent, and it is also wrong about
an empty table.
Both ORMs N+1 the same way, and only one of them can be told not to
Lazy loading is identical on both sides, and so is the bill. Ten posts, each touching its author,
counted with assertNumQueries on the Django side and assert_queries_count on the Rails side: 11
queries both times. select_related("author") takes Django to 1, a single JOIN. includes(:author)
takes Rails to 2, the posts then the authors in one IN.
Where they part is what happens when you forget. Rails has a switch that turns the lazy load into an exception, and the message is specific enough to fix from:
test "strict_loading turns the lazy load into an exception" do
error = assert_raises(ActiveRecord::StrictLoadingViolationError) do
Post.strict_loading.each { |post| post.author.name }
end
assert_equal "`Post` is marked for strict_loading. The Author association named " \
"`:author` cannot be lazily loaded.", error.message
end
Django 6.1.1 has no equivalent, and the check for that is a grep of the installed package:
grep -rn "strict_loading" site-packages/django/ returns nothing. The nearest thing is the reverse
trap. Post.objects.only("title") defers the other columns, and reading post.body afterwards
reloads the row, once per row, silently. That cost 11 queries in the same test where the eager
version cost 1, and nothing in Django will tell you it is happening.
Both suites pass, and they are the proof that the paragraphs above are not recollection:
$ bin/rails test test/models/lazy_loading_test.rb
4 runs, 10 assertions, 0 failures, 0 errors, 0 skips
$ python manage.py test blog
Ran 4 tests in 0.018s
OK
The admin is the one feature with no Rails answer
Django's admin is not a scaffold you generate and then own. Registering the model is the whole integration, and this is the entire file:
from django.contrib import admin
from blog.models import Author, Post
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = ("title", "author", "published_at")
list_filter = ("published_at",)
search_fields = ("title", "body")
admin.site.register(Author)
Thirteen lines, imports included. Logging in with curl and fetching the changelist:
$ curl -s -b cj.txt -o adminlist.html -w "%{http_code} %{size_download}\n" http://127.0.0.1:8987/admin/blog/post/
200 49769
$ grep -o "<title>[^<]*</title>" adminlist.html
<title>Select post to change | Django site admin</title>
$ grep -c "field-title" adminlist.html
100
100 rows on the first page of 500, with the pagination, the date filter, the search box and the edit
forms behind every row. Search comes from the search_fields line alone: ?q=Post+17 came back with 15 results. The equivalent request on the Rails side:
$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3987/admin
404
There is no Rails admin. ActiveAdmin, Administrate and Avo exist and are good, and all three are a
gem you choose, version, style and upgrade, and none of them is what rails new gives you. If the
product is a CRUD interface that an internal team drives, that difference is a week of work and a
permanent maintenance line, and it is the strongest single argument for Django on this page.
Django 6.1 has a background task API and no worker
django.tasks is new and it is real: @task(), .enqueue(), a TaskResult with a status. What
ships with it is two backends, and the directory listing is the claim:
$ ls venv/lib/python3.14/site-packages/django/tasks/backends/
__init__.py base.py dummy.py immediate.py
ImmediateBackend runs the task in the calling process, right now. Timed:
enqueue() returned after 0.201 s
status: SUCCESSFUL
return value: ran in pid 98913 with n=7
caller pid: 98913
The task slept 0.2 seconds and enqueue() blocked for all of it, in the web process, on the request.
manage.py help lists 31 subcommands and none of them is a worker. Durable queueing in Django in
2026 is Celery or django-q or a Postgres table you write, and that is a dependency, a broker and a
deployment unit you choose yourself.
Rails 8 ships Solid Queue, with a database-backed queue and a worker binary:
perform_later returned after 0.085 s
caller pid: 99953
solid_queue_jobs rows: 1, ready: 1
Then bin/jobs in another terminal took it: ready: 0, finished: 1. The cost of that is a schema,
13 solid_queue_* tables in this app, and a second process to run and monitor. It is not free, it is
just already decided.
Templates: Django's swallow, ERB raises
Django's template language is deliberately not Python, and the deliberate part is what bites. Four
expressions rendered against a real Post:
[{{ post.title|truncatechars:8 }}] [{{ post.nope }}] [{{ post.title.upper }}] [{{ post.body.count }}]
[Post 0] [] [POST 0] []
post.nope does not exist and renders an empty string. post.body.count exists, needs an argument,
cannot be given one, and also renders an empty string. Neither raises, neither logs, and a typo in a
field name ships as a blank cell.
ERB is Ruby with tags around it, so the same expressions, with the argument that Django's syntax has no way to pass:
$ bin/rails runner 'require "erb"; post = Post.first
puts ERB.new("[<%= post.title.truncate(8) %>] [<%= post.title.upcase %>] [<%= post.body.count(\"x\") %>]").result(binding)
begin; ERB.new("[<%= post.nope %>]").result(binding); rescue NoMethodError => e; puts "NoMethodError: #{e.message.lines.first.strip}"; end
begin; ERB.new("[<%= post.body.count %>]").result(binding); rescue ArgumentError => e; puts "ArgumentError: #{e.message.lines.first.strip}"; end'
[Post 0] [POST 0] [400]
NoMethodError: undefined method 'nope' for an instance of Post
ArgumentError: wrong number of arguments (given 0, expected 1+)
The argument for Django's design is that it keeps logic out of templates and lets a non-developer edit them safely. That argument holds. The cost is that the failure mode of a view is silence, and silence is the failure mode you find in production rather than in review.
The same endpoint on both, and the run that was wrong
The first benchmark was not a fair one, and it is worth printing because it is exactly how these
comparisons get faked. Puma was in its default single mode with 3 threads and ApacheBench was running
with -k; gunicorn was a single sync worker, which is one thread and supports no keep-alive at all.
Rails came out at 499.98 requests per second, Django at 166.09, and the gap was partly mine.
Re-run with one worker and one thread on each side and no keep-alive, ab -n 3000 -c 12 after a
500-request warm-up:
=== RAILS 1 worker 1 thread, no keep-alive ===
Requests per second: 421.93 [#/sec] (mean)
Time per request: 28.441 [ms] (mean)
Total: 3 28 3.2 28 45
=== DJANGO 1 worker 1 thread, no keep-alive ===
Requests per second: 163.91 [#/sec] (mean)
Time per request: 73.213 [ms] (mean)
Total: 11 73 12.7 70 140
Both returned Failed requests: 0 and bodies of 22,541 and 22,841 bytes. At -c 1 the per-request
figures were 3.224 ms for Rails and 6.857 ms for Django.
Taking HTTP out of it entirely, the query and the JSON encode in-process, 2000 iterations after 200 warm-ups: 0.761 ms on Active Record, 1.834 ms on the Django ORM. So roughly a millisecond of the 3.6 ms gap is the ORM and the object instantiation, and the rest is the request stack. Which is the part that surprised me: Rails booted 20 Rack middlewares in production against Django's 7, and is still the faster of the two on this endpoint. The middleware count is not where the time goes, and "Rails is heavier" turns out to be a statement about disk, not about a request.
One caveat on all of it. This is one endpoint, one machine, one afternoon, read-only, over loopback, with no YJIT on the Ruby side and no free-threading on the Python side. A 2.6x on a 50-row JSON serialisation is not a 2.6x on your application, and if throughput is what you are choosing on, the argument in Ruby on Rails performance comparison applies to both frameworks equally: the framework is almost never the slow part.
What each one drags in
rails new with no skip flags wrote 87 files and reported Bundle complete! 23 Gemfile dependencies,
123 gems now installed. django-admin startproject plus manage.py startapp blog wrote 13 files,
and pip install django in an empty venv installs three packages: Django, asgiref and sqlparse.
Those 123 gems are Solid Queue, Solid Cache, Solid Cable, Propshaft, Turbo, Stimulus, Kamal, Thruster, Brakeman, Bundler Audit and Rubocop, all wired up. The 3 Python packages are a framework, an ORM, an admin and a template engine, and everything else is a decision you have not made yet.
The call, and what would change it
Pick Django when an admin interface is a deliverable rather than a convenience, or when Python is already in the process for a reason that is not the web layer. The admin is genuinely a week you do not spend, the Rails alternatives are all third-party, and if the same repository also runs models, notebooks or a data pipeline then the language question is already settled and the framework question is downstream of it.
Pick Rails for anything customer-facing that has to run in production next month. More of what you will need is already in the box and already chosen: a durable queue with a worker, a cache, a websocket layer, a deploy tool, and a front-end story that does not require a second application. Django 6.1's task API is the clearest illustration; it is a good API and it still leaves the actual running of jobs to you.
What would change this: a durable backend and a worker landing in django.tasks would delete the
strongest half of the Rails argument, and it is plainly where that API is heading. And an admin
shipping in rails new would delete the strongest half of the Django one, which is not on anybody's
roadmap.
What should not change it is the throughput number above. A 2.6x on one JSON endpoint is worth less than the fact that your team already writes one of these two languages well, and on a page where I did measure it, that is the recommendation I would defend hardest.
What this post does not cover
No async. Django's ASGI stack, async def views and sync_to_async are a real and growing half of
that framework, and every measurement here went through WSGI on gunicorn's sync worker, which is the
configuration least flattering to it. Rails' equivalents, Falcon and the async gem, are equally
absent.
Also absent: Django REST Framework and Rails' API mode, which is where most people writing a JSON
backend actually live; authentication, which both ship and which differ substantially; deployment,
since everything ran on loopback; multi-process throughput, so no WEB_CONCURRENCY and no gunicorn
--workers; PostgreSQL tuning, because the database was never the constraint; and any comparison of
the two ecosystems, hiring markets or library availability, none of which can be measured from a
laptop.
Two things I could not verify and therefore did not claim. No cross-framework benchmark from a third
party is quoted here, for the reason given on the performance page: techempower.com/benchmarks
serves an empty document to a fetch. And nothing about Django's future release plans, because a
roadmap is not a primary source about what the code does today.
Comments
No comments yet. Be the first.