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:

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:

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:

  1. Latest page — topic list with author, last poster, and category
  2. Topic header — title, views, likes, category, author
  3. Post stream — first 20 cooked posts with user data
  4. User card — profile-ish aggregate: posts, likes, last post date
  5. Category dashboard — grouped category/topic counts
  6. Autosave/event write — insert one temporary event
  7. 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

Bar chart showing MiniSql faster than ActiveRecord in Jarvis container and wasnotwas Docker runs

Local Jarvis container

This is the faster environment: the Jarvis container with local PostgreSQL.

RubyLayerSessions/secOps/secRows materialized
3.4.6MiniSql650.674554.662,223,975
3.4.6ActiveRecord324.002267.991,106,295
4.0.5MiniSql641.954493.632,194,318
4.0.5ActiveRecord338.132366.881,154,062

MiniSql advantage:

RubyMiniSql vs ActiveRecord
3.4.62.01×
4.0.51.90×

Ruby 4 advantage:

LayerRuby 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.

RubyLayerSessions/secOps/secRows materialized
3.4.6MiniSql146.061022.41498,541
3.4.6ActiveRecord55.11385.77188,323
4.0.5MiniSql139.35975.47475,911
4.0.5ActiveRecord57.72404.01197,107

MiniSql advantage:

RubyMiniSql vs ActiveRecord
3.4.62.65×
4.0.52.41×

Ruby 4 advantage:

LayerRuby 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:

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 shapeBetter default
CRUD screensActiveRecord
Validated domain writesActiveRecord
Business objects with callbacksActiveRecord
Simple admin formsActiveRecord
Reporting queriesMiniSql
Hot read pathsMiniSql
Complex joins / aliases / hand-tuned query plansMiniSql
“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.