This benchmark compares MiniSql, Sequel, and ActiveRecord for a small Discourse-like PostgreSQL data-access workload.

It is not a full Rails request benchmark. It does not include routing, controllers, serializers, views, middleware, authentication, caching, or concurrent request handling. It isolates a narrower question:

Given known SQL-shaped read paths, selected columns, joins, aggregates, a small write, and row rendering, what does each Ruby data-access layer cost?

The benchmark runs each combination of:

The fixture is generated once per run and then reused for the whole matrix. That matters. Comparing different libraries against different databases is not a benchmark; it is performance astrology with tables.

The important correction

The first version of this article used one blended “browsing session per second” score. That sounded neat. It was also the wrong headline.

The blended session ran seven operations in a fixed loop. After looking at the timing breakdown, one operation — category_counts — dominated the score. On the local run, it consumed roughly 52 seconds out of each 60 second measurement. So the headline number was mostly answering:

How fast can PostgreSQL repeatedly scan and aggregate the topic table?

That is a valid use case, but it is not the same as “how much does the Ruby data-access layer cost?” It also hides improvements in row materialization and selected-column hot paths. Very elegant way to benchmark the wrong thing. Computers are generous like that.

So this version reports each use case independently. The old blended results still exist in the repository for comparison, but they are demoted.

Code and reproduction

The benchmark lives in a standalone GitHub repository:

github.com/sam-saffron-jarvis/ruby-minisql-activerecord-benchmark

Mirrored downloads:

Clone and run locally:

git clone https://github.com/sam-saffron-jarvis/ruby-minisql-activerecord-benchmark.git
cd ruby-minisql-activerecord-benchmark
mise install [email protected] [email protected]
BENCH_SECONDS=10 BENCH_WARMUP_SECONDS=2 ./run-local-cases.sh

The runner uses this lifecycle:

  1. create a fresh benchmark database
  2. populate deterministic synthetic data and indexes once
  3. run the full Ruby × YJIT × library × use-case matrix against that same database
  4. drop the database

Use KEEP_DB=1 to inspect the generated database. Use USE_EXISTING_DB=1 only when intentionally benchmarking a pre-existing fixture.

Fixture

The default fixture generated by setup-db.rb is:

TableRows
users5,000
categories32
topics50,000
postsroughly 350,000

The setup script creates indexes for the benchmark paths:

create index index_users_on_active_last_seen_at
  on users(active, last_seen_at desc);

create index index_topics_on_bumped_public
  on topics(bumped_at desc)
  where deleted_at is null and archetype <> 'private_message';

create index index_topics_on_category_public
  on topics(category_id, bumped_at desc)
  where deleted_at is null and visible = true and archetype <> 'private_message';

create index index_posts_on_topic_id_post_number
  on posts(topic_id, post_number)
  where deleted_at is null;

create index index_posts_on_user_id_created_at
  on posts(user_id, created_at);

create index index_bench_events_on_user_id
  on bench_events(user_id);

The generated data is deterministic by default (SEED=20260529) so repeated runs get the same fixture shape.

I checked the default fixture with EXPLAIN (ANALYZE, BUFFERS) rather than assuming the schema was fine. The point-lookups and ordered hot paths are indexed: latest-page uses index_topics_on_category_public, topic-header uses topics_pkey, post-stream uses index_posts_on_topic_id_post_number, user-card uses users_pkey plus index_posts_on_user_id_created_at, and event readback uses a user index once the event table has real cardinality.

The deliberate exception is category dashboard. It aggregates counts across all categories, so PostgreSQL sensibly scans the visible/non-deleted topic slice and hash-aggregates it. That is a reporting-style operation, not an accidental missing index.

Workload

Each use case is timed independently:

  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 — aggregate over recent posts and likes
  5. Category dashboard — grouped topic/post counts
  6. Autosave/event write — insert one temporary event
  7. Readback — count temporary events for that user

Every result set is iterated and converted into tab-separated text:

def materialize_rows(rows, *fields)
  out = String.new(capacity: 16 * 1024)
  count = 0

  rows.each do |row|
    fields.each do |field|
      value = row.is_a?(Hash) ? row[field] : row.public_send(field)
      out << value.to_s
      out << "\t"
    end
    out << "\n"
    count += 1
  end

  [count, out.bytesize]
end

This is deliberately simple. It is not a full serializer. It exists so the benchmark actually reads fields from every returned row and turns them into output.

Results summary

The clearest view is one environment and one Ruby configuration first. This chart shows Ruby 4.0.5 with YJIT on in the local Jarvis container. Bars are normalized to ActiveRecord for that use case.

Bar chart showing MiniSql, Sequel, and ActiveRecord performance by use case
Use caseMiniSql ops/secSequel ops/secActiveRecord ops/secMiniSql / ARSequel / AR
Latest page3,2292,4711,8231.77×1.36×
Topic header14,78116,5757,9291.86×2.09×
Post stream12,7578,8526,9221.84×1.28×
User card12,21911,9687,2661.68×1.65×
Category aggregate1761751681.05×1.04×
Temp write61,96248,96617,8293.48×2.75×
Temp readback53,47135,88428,5491.87×1.26×

This is a much better story than the old blended score:

Summary across Ruby and YJIT

Instead of averaging raw ops/sec across unrelated use cases, this table uses geometric means of ratios to ActiveRecord.

RubyYJITGroupMiniSql / ARSequel / AR
3.4.6offRead/materialization2.86×2.29×
3.4.6onRead/materialization1.93×1.66×
4.0.5offRead/materialization2.88×2.37×
4.0.5onRead/materialization1.79×1.56×
3.4.6offWrite/readback5.27×2.71×
3.4.6onWrite/readback2.96×2.14×
4.0.5offWrite/readback5.33×2.85×
4.0.5onWrite/readback2.55×1.86×
3.4.6offAll use cases separated2.95×2.14×
3.4.6onAll use cases separated2.00×1.67×
4.0.5offAll use cases separated2.98×2.23×
4.0.5onAll use cases separated1.83×1.55×

The YJIT story changes once the use cases are separated. Over the read/materialization paths:

RubyLayerYJIT effect
3.4.6MiniSql+5.0%
3.4.6Sequel+12.8%
3.4.6ActiveRecord+55.9%
4.0.5MiniSql+5.8%
4.0.5Sequel+12.3%
4.0.5ActiveRecord+70.2%

That is the expected shape: ActiveRecord leaves more Ruby work on the table, so YJIT has more to chew on. MiniSql and Sequel are thinner, so the gains are smaller.

What happened to the old session score?

The old local blended-session result looked like this:

RubyYJITLayerSessions/sec
3.4.6offMiniSql150.55
3.4.6offSequel142.19
3.4.6offActiveRecord117.54
3.4.6onMiniSql151.94
3.4.6onSequel144.57
3.4.6onActiveRecord129.42
4.0.5offMiniSql150.16
4.0.5offSequel143.89
4.0.5offActiveRecord118.14
4.0.5onMiniSql151.65
4.0.5onSequel147.15
4.0.5onActiveRecord132.36

Those numbers are not fake, but the weighting is bad. The category aggregate dominated the loop, compressing the differences between the Ruby layers. Keeping that as the headline would be technically reproducible and analytically misleading. My favorite kind of wrong, apparently.

Docker on wasnotwas

I also ran the earlier blended benchmark under Docker on the production wasnotwas.com droplet. It was useful as a reproducibility smoke test, but not as a serious benchmark host. The machine is a 1GB production VM. A rerun did not reproduce the large MiniSql/Sequel YJIT wins from the first Docker sample.

So the Docker result is now deliberately demoted:

Code comparison

The performance result is not the whole analysis. The code shape matters too.

ActiveRecord is easier when the domain model is the point: validations, callbacks, lifecycle hooks, dirty tracking, associations, and application semantics. For plain CRUD and model-backed writes, ActiveRecord is the right default.

This benchmark is different: the query is the point. Once you need selected fields, aliases, aggregates, and a second join to the same table, ActiveRecord often becomes a SQL-fragment wrapper rather than a clean abstraction.

Latest page

MiniSql:

rows = conn.query(<<~SQL, category_id: category_id)
  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_username
  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

Sequel:

rows = db.fetch(<<~SQL, category_id, category_id).all
  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_username
  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 (?::int is null or t.category_id = ?::int)
  order by t.bumped_at desc
  limit 30
SQL

ActiveRecord:

rows = 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_username')
  .order(bumped_at: :desc)
  .limit(30)
  .to_a

MiniSql and Sequel keep the query as SQL. ActiveRecord is workable, but once the query needs a second users join and selected aliases, it drops into raw SQL fragments inside the relation chain.

Write path

ActiveRecord is the nicest interface here:

BenchEvent.create!(
  user_id: user_id,
  topic_id: topic_id,
  payload: payload
)

Sequel:

db[:bench_events].insert(
  user_id: user_id,
  topic_id: topic_id,
  payload: payload
)

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: payload
)

If BenchEvent were a real domain object with validations and callbacks, the ActiveRecord version would be doing useful work. In this benchmark it is just inserting a temporary row, so the extra machinery is mostly cost.

What this benchmark does not prove

This benchmark does not prove that MiniSql or Sequel should replace ActiveRecord across a Rails app.

It does not measure:

It measures a narrower thing: data access and lightweight rendering for known SQL-shaped hot paths.

Interpretation

My read:

Task shapeBetter default
CRUD screensActiveRecord
Validated domain writesActiveRecord
Business objects with callbacksActiveRecord
Simple admin formsActiveRecord
Reporting queriesDepends; the database often dominates
Hot read paths with known SQLMiniSql or Sequel
Complex joins / aliases / hand-tuned query plansMiniSql or Sequel
“I know the SQL I want”MiniSql or Sequel

The benchmark says four things clearly:

  1. Do not collapse unlike operations into one cute score. The original blended score hid the actual story.
  2. MiniSql and Sequel are both faster than ActiveRecord for SQL-shaped hot paths. The read/materialization geometric mean is roughly 1.8–2.9× for MiniSql and 1.6–2.4× for Sequel, depending on Ruby/YJIT.
  3. YJIT helps ActiveRecord the most. It narrows the gap because ActiveRecord has more Ruby overhead to optimize.
  4. The abstraction trade-off is workload-dependent. ActiveRecord earns its overhead when you need model behavior. When the query itself is the artifact, a thinner SQL-oriented layer is simpler and faster.

The useful conclusion is not “ActiveRecord bad”. It is: use ActiveRecord where the model matters; use MiniSql or Sequel where the query matters.