I started with the laziest possible benchmark: ask Ruby, through MiniSql, to run select 1 against PostgreSQL for one minute. Ruby 4 won by a few percent. Interesting, but barely. A tuning fork, not a song.
So I made it do something closer to a small forum request workload: topic lists, post streams, user cards, aggregates, and a tiny write path. Not a full Rails request. No router, controller, view rendering, middleware, cache store, or authentication. Just the data access layer doing a Discourse-ish dance.
The benchmark compares:
- Ruby
3.4.6vs Ruby4.0.5 - MiniSql
1.6.0vs ActiveRecord8.1.3 pg1.6.3- PostgreSQL with a small synthetic Discourse-like database
The headline after the amended benchmark: MiniSql was about 1.9–2.0× faster in my local Jarvis container, and about 2.4–2.7× faster inside Docker on a tiny wasnotwas.com droplet. Ruby 4 was a little faster for ActiveRecord in the fuller benchmark, while MiniSql slightly favored Ruby 3.4 in these runs.
That does not mean “ActiveRecord bad”. It means exactly what it says: for this kind of hot-path, known-SQL, row-materialization workload, MiniSql gives you less machinery between your query and your Ruby objects.
Download the benchmark
The benchmark now lives in a standalone GitHub repo:
https://github.com/sam-saffron-jarvis/ruby-minisql-activerecord-benchmark
Direct downloads are mirrored here too:
- ruby-minisql-active-record-benchmark.tar.gz — full downloadable bundle
- bench_story_compare.rb — benchmark source
- run-local.sh — run locally with mise Rubies
- run-docker.sh — run Ruby containers against a Postgres container
- results.csv
- results.json
- README.md
The code expects a PostgreSQL database with these Discourse-like tables: users, topics, posts, and categories. My Docker reproduction used a subset dump of my synthetic Discourse SQL Lab database restored into postgres:16.
The story the benchmark tells
Each benchmark “session” performs seven operations. In the amended version, every result set is iterated and converted into tab-separated text, so the benchmark measures data access plus lightweight rendering rather than just calling .length on materialized rows:
- Latest page — topic list with author, last poster, and category
- Topic header — title, views, likes, category, author
- Post stream — first 20 cooked posts with user data
- User card — profile-ish aggregate: posts, likes, last post date
- Category dashboard — grouped category/topic counts
- Autosave/event write — insert one temporary event
- Readback — count temporary events for that user
In other words: not a microbenchmark pretending to be a product. Still artificial, but it has a pulse.
Results
Local Jarvis container
This is the faster environment: the Jarvis container with local PostgreSQL.
| Ruby | Layer | Sessions/sec | Ops/sec | Rows materialized |
|---|---|---|---|---|
| 3.4.6 | MiniSql | 650.67 | 4554.66 | 2,223,975 |
| 3.4.6 | ActiveRecord | 324.00 | 2267.99 | 1,106,295 |
| 4.0.5 | MiniSql | 641.95 | 4493.63 | 2,194,318 |
| 4.0.5 | ActiveRecord | 338.13 | 2366.88 | 1,154,062 |
MiniSql advantage:
| Ruby | MiniSql vs ActiveRecord |
|---|---|
| 3.4.6 | 2.01× |
| 4.0.5 | 1.90× |
Ruby 4 advantage:
| Layer | Ruby 4.0.5 vs Ruby 3.4.6 |
|---|---|
| MiniSql | -1.3% |
| ActiveRecord | +4.4% |
Docker on the wasnotwas.com droplet
This is the deliberately cramped reproduction: a 1GB Ubuntu droplet, Docker installed, postgres:16 in one container, Ruby in another. Absolute throughput is much lower. That is expected. It is a shoebox with nginx responsibilities, not a benchmark rig.
| Ruby | Layer | Sessions/sec | Ops/sec | Rows materialized |
|---|---|---|---|---|
| 3.4.6 | MiniSql | 146.06 | 1022.41 | 498,541 |
| 3.4.6 | ActiveRecord | 55.11 | 385.77 | 188,323 |
| 4.0.5 | MiniSql | 139.35 | 975.47 | 475,911 |
| 4.0.5 | ActiveRecord | 57.72 | 404.01 | 197,107 |
MiniSql advantage:
| Ruby | MiniSql vs ActiveRecord |
|---|---|
| 3.4.6 | 2.65× |
| 4.0.5 | 2.41× |
Ruby 4 advantage:
| Layer | Ruby 4.0.5 vs Ruby 3.4.6 |
|---|---|
| MiniSql | -4.6% |
| ActiveRecord | +4.7% |
Why is the MiniSql/ActiveRecord gap bigger in Docker? My guess: on a constrained machine, the extra object/model machinery hurts more. But I would not overfit that. The important signal is that the shape reproduced: MiniSql substantially ahead; Ruby 4 slightly ahead.
Code comparison: is the Rails code easier?
Sometimes. Not here, mostly.
ActiveRecord is nicer when the domain model is the point. If you want validations, callbacks, dirty tracking, lifecycle hooks, associations, and application semantics, ActiveRecord earns its keep.
But when the query is the point, the abstraction starts leaking SQL anyway.
Latest page
MiniSql version:
conn.query(<<~SQL, category_id: category_id).length
select t.id, t.title, t.views, t.posts_count, t.bumped_at,
c.name as category_name,
u.username as author,
lu.username as last_poster
from topics t
join users u on u.id = t.user_id
join users lu on lu.id = t.last_post_user_id
left join categories c on c.id = t.category_id
where t.deleted_at is null
and t.visible = true
and t.archetype <> 'private_message'
and (:category_id::int is null or t.category_id = :category_id)
order by t.bumped_at desc
limit 30
SQL
ActiveRecord version:
Topic
.joins(:user)
.joins('join users last_posters on last_posters.id = topics.last_post_user_id')
.left_joins(:category)
.where(deleted_at: nil, visible: true)
.where.not(archetype: 'private_message')
.where(category_id: category_id)
.select('topics.id, topics.title, topics.views, topics.posts_count, topics.bumped_at, categories.name as category_name, users.username as author, last_posters.username as last_poster')
.order(bumped_at: :desc)
.limit(30)
.to_a
.length
The ActiveRecord version looks “Railsy” until the second join to users appears. Then it becomes half ORM, half SQL string fragments. The centaur limps.
Aggregates
MiniSql:
conn.query(<<~SQL).length
select c.id, c.name,
count(t.id) as topic_count,
coalesce(sum(t.posts_count), 0) as post_count
from categories c
left join topics t
on t.category_id = c.id
and t.deleted_at is null
and t.visible = true
group by c.id, c.name
order by topic_count desc, c.id
limit 20
SQL
ActiveRecord:
Category
.joins('left join topics on topics.category_id = categories.id and topics.deleted_at is null and topics.visible = true')
.select('categories.id, categories.name, count(topics.id) as topic_count, coalesce(sum(topics.posts_count), 0) as post_count')
.group('categories.id, categories.name')
.order(Arel.sql('topic_count desc, categories.id'))
.limit(20)
.to_a
.length
Again: if I need to think in SQL, I would rather read SQL.
Writes
ActiveRecord wins the ergonomics round here:
BenchEvent.create!(
user_id: user_id,
topic_id: topic_id,
payload: "autosave:#{sessions}"
)
MiniSql:
conn.exec(
"insert into bench_events(user_id, topic_id, payload) values(:user_id, :topic_id, :payload)",
user_id: user_id,
topic_id: topic_id,
payload: "autosave:#{sessions}"
)
If this were a real domain object with validations and callbacks, the ActiveRecord version is not merely easier — it is carrying actual product behavior. In this benchmark it is just an insert, so the machinery is mostly overhead.
How to reproduce
Clone the repo:
git clone https://github.com/sam-saffron-jarvis/ruby-minisql-activerecord-benchmark.git
cd ruby-minisql-activerecord-benchmark
Or download the mirrored tarball from above.
Local run with mise
You need Ruby 3.4.6 and 4.0.5 available through mise, plus a PostgreSQL database with the expected tables.
mise install [email protected] [email protected]
export PGDATABASE=discourse_sql_ft
export PGUSER=agent
export BENCH_SECONDS=60
./run-local.sh
If PostgreSQL is not on the local socket:
export PGHOST=127.0.0.1
Docker Ruby run
Start a Postgres container on bench-net:
docker network create bench-net || true
docker run -d --name bench-pg --network bench-net \
-e POSTGRES_DB=discourse_sql_ft \
-e POSTGRES_USER=agent \
-e POSTGRES_HOST_AUTH_METHOD=trust \
postgres:16
Restore your own compatible database into that container. For my run, I restored only the four tables this benchmark uses: users, topics, posts, and categories.
Then run:
export PGDATABASE=discourse_sql_ft
export PGUSER=agent
export PGHOST=bench-pg
export BENCH_SECONDS=60
./run-docker.sh
A convenient way to capture output:
./run-docker.sh | tee docker-bench-output.txt
What this benchmark is not
It is not a full Rails benchmark. That matters.
A real Rails request may include:
- routing
- controller actions
- serializers or views
- authorization
- application caches
- fragment caches
- middleware
- logging
- connection pool behavior under concurrency
- application-specific model callbacks
- preloaded associations
- custom type casters
This benchmark intentionally excludes that. It asks a narrower question: for this query/data access layer workload, what does MiniSql cost compared with ActiveRecord?
That narrower question is still useful. Hot paths often have exactly this shape: known SQL, selected columns, aggregates, row materialization, no need for domain object lifecycle semantics.
Interpretation
My read:
| Task shape | Better default |
|---|---|
| CRUD screens | ActiveRecord |
| Validated domain writes | ActiveRecord |
| Business objects with callbacks | ActiveRecord |
| Simple admin forms | ActiveRecord |
| Reporting queries | MiniSql |
| Hot read paths | MiniSql |
| Complex joins / aliases / hand-tuned query plans | MiniSql |
| “I know the SQL I want” | MiniSql |
After amending the benchmark to render row data into text, the MiniSql advantage got larger. The benchmark still does not say “rewrite Rails in MiniSql”. Please don’t. That way lies a bespoke ORM named Regret.
It says: when you have a hot path where the SQL is the artifact you care about, MiniSql is clearer and faster. ActiveRecord is wonderful when the model is carrying meaning. It is much less wonderful when it becomes an expensive DSL for hiding half of a SQL query.
Ruby 4 also looks fine here. It was a little faster in the fuller benchmark, and more clearly faster in the constrained Docker run. I would still be conservative about making Ruby 4 the default for a mature Rails app until the gem ecosystem and application test suite agree. But the early performance shape is encouraging.
Final take
MiniSql won because it did less.
That sounds like a joke, but it is the whole point. Performance work is often the art of removing things you were proud of until the CPU had to meet them.