← Rails & Ruby Frameworks

Sequel ORM and PostgreSQL: The Performance-First Alternative Your Ruby Application Deserves

ActiveRecord is the default. Sequel is the choice you make after measuring.

The Waiter of Gold Lapel · Updated Mar 20, 2026 Published Mar 5, 2026 · 28 min read
The illustration arrived 3.9 times late. We have since switched renderers.

Good evening. I notice you are still using the default ORM.

There is nothing wrong with defaults, as a rule. Default salt levels. Default hem lengths. Default tip percentages. Defaults exist because someone competent chose them, and most of the time they serve well enough.

ActiveRecord is the default ORM for Ruby applications. It ships with Rails. It is assumed in virtually every tutorial, every conference talk, every "Build a Blog in 15 Minutes" screencast that has ever been committed to YouTube. If you have written Ruby professionally, you have written ActiveRecord code. You have called .where and .includes and .find_by, and the SQL that emerged was reasonable, and your application worked.

This is fine. I would never disparage a functioning household.

But I would — with the utmost courtesy — note that functioning and efficient are different matters entirely. And that the distance between them, in the case of ActiveRecord, is between 2x and 7x.

Sequel is the ORM that Ruby developers discover after they have spent a weekend profiling their application and staring at allocation flamegraphs with a growing sense of unease. It is the ORM that appears in your search results at 2am, when you are trying to understand why a page listing 200 orders allocates 9,000 Ruby objects. It is faster, leaner, more PostgreSQL-aware, and — once you understand its extension system — considerably more capable.

It is also less popular. Less documented in blog posts. Almost never the subject of conference talks. Sequel is the ORM equivalent of the quiet sommelier who has been at the estate for thirty years while the charismatic new hire gets all the attention. Both can recommend a wine. One knows where every bottle is stored.

I intend to correct the visibility imbalance today. This is a comprehensive guide to every PostgreSQL performance optimization Sequel offers — the ones gathered from extension docs that most developers never find, from source code that most developers never read, and from production experience that most blog posts never share. Allow me to take your coat.

The numbers, before the narrative

I believe in presenting evidence before arguments. A waiter who leads with opinion and follows with data has the order backwards. Here are benchmarks run on Ruby 3.3 with YJIT enabled, PostgreSQL 16, identical schema and indexes, 500,000 rows in the orders table, 50,000 customers. Measured at p50, averaged over 1,000 iterations after warmup.

OperationSequelActiveRecordSequel advantage
Simple SELECT (1 row by PK)0.08ms0.31ms3.9x
SELECT with WHERE + ORDER (50 rows)0.42ms1.14ms2.7x
JOIN + filter (200 rows)1.1ms3.8ms3.5x
Aggregation (GROUP BY, SUM, COUNT)0.9ms1.6ms1.8x
INSERT 1,000 rows (bulk)12ms84ms7.0x
paged_each / find_each (100k rows)820ms2,400ms2.9x
Eager load (200 orders + customers)1.4ms3.2ms2.3x
Memory per 10k fetched rows4.2 MB18.6 MB4.4x

The gap is not subtle. A simple primary key lookup — the most common database operation in any web application — is nearly 4x faster. Bulk inserts are 7x faster. Memory consumption per fetched row is 4.4x lower. And these numbers hold under YJIT, which already gives ActiveRecord a significant boost. Without YJIT, the gaps widen further.

I should note that aggregation shows the smallest difference — 1.8x. This is because PostgreSQL does the heavy lifting for aggregations; the ORM merely sends the query and receives a handful of rows. When the database does the work, the ORM's overhead matters less. Keep this in mind. It will become relevant when we discuss where ActiveRecord's overhead actually lives.

The question is not whether Sequel is faster. That has been settled. The question is why, and what specific features you should enable to capture the full advantage. That is what the rest of this guide attends to.

The dataset abstraction: why Sequel thinks differently

Before we discuss specific optimizations, it is worth understanding the fundamental architectural difference between Sequel and ActiveRecord. ActiveRecord's primary abstraction is the model — a Ruby class that maps to a database table. The model is the entry point for everything: querying, creating, updating, validating, associating.

Sequel's primary abstraction is the dataset — an object that represents a SQL query. A dataset is not a model. It is not a table. It is a query that has not yet been executed. You compose it, filter it, sort it, join it — and nothing touches the database until you explicitly ask for results.

Sequel datasets — queries as first-class objects
# Sequel's dataset abstraction — queries as first-class objects

# A dataset is an object that represents a SQL query.
# It does NOT execute until you tell it to.

pending = DB[:orders].where(status: 'pending')
# No query yet. pending is just a dataset object.

recent = pending.where { created_at >= Date.today - 30 }
# Still no query. recent is a new dataset derived from pending.

sorted = recent.order(Sequel.desc(:total))
# Still no query. Datasets are immutable and composable.

# Now execute:
results = sorted.limit(20).all
# NOW the query runs:
# SELECT * FROM "orders"
#   WHERE ("status" = 'pending')
#   AND ("created_at" >= '2026-02-09')
#   ORDER BY "total" DESC LIMIT 20

# This matters because you can pass datasets around as arguments,
# compose them in methods, and let the final consumer decide
# when to execute. The dataset IS the query plan — inspect it:
puts sorted.sql
# => SELECT * FROM "orders" WHERE ("status" = 'pending')
#    AND ("created_at" >= '2026-02-09') ORDER BY "total" DESC

This is not merely a design preference. It has practical consequences.

First, datasets are immutable. Calling .where on a dataset returns a new dataset. The original is unchanged. This means you can safely pass datasets into methods, store them as constants, and compose them without worrying about accidental mutation. ActiveRecord scopes can do something similar, but the underlying mechanism is a mutable relation object that has bitten enough developers to be worth mentioning.

Second, datasets are inspectable. You can call .sql on any dataset and see exactly what SQL it will generate. Before execution. This is invaluable during development — you are never guessing what query your ORM will produce. ActiveRecord's .to_sql provides similar functionality, though it requires the query to be fully composed first.

Third — and this is the performance-relevant distinction — datasets return plain Ruby hashes by default. Not model objects. Not instances of your class with forty-five allocations each. Hashes. When you do want model objects, you use a model class that wraps a dataset. But the dataset itself carries no model overhead.

sequel_pg: the single biggest performance win

If you install Sequel and do nothing else — no plugins, no extensions, no configuration — install sequel_pg. It is a C extension that replaces Sequel's pure-Ruby row fetching with optimized C code that builds Ruby hashes directly from libpq's internal buffers.

sequel_pg — drop-in C-optimized fetching
# sequel_pg — C-optimized result fetching
#
# Install: gem install sequel_pg
# Then just require it. That's it.
require 'sequel'
require 'sequel_pg'  # drops in automatically

DB = Sequel.connect('postgres://localhost/myapp')

# sequel_pg replaces the pure-Ruby row fetching with a C extension
# that builds Ruby hashes directly from libpq buffers.
#
# Before (pure Ruby pg gem):
#   1. libpq returns binary row data
#   2. pg gem converts to Ruby strings
#   3. Sequel converts strings to Ruby types (Integer, Date, etc.)
#   4. Sequel builds hash with symbol keys
#
# After (sequel_pg):
#   1. libpq returns binary row data
#   2. sequel_pg converts directly to typed Ruby hash in C
#   Steps 2-4 happen in one pass, in C, with zero intermediate objects.
#
# Result: 2-3x faster fetching, 50-80% less memory allocation.

The performance impact is immediate and dramatic. Fetching 10,000 rows goes from ~178ms (pure Ruby pg gem parsing) to ~58ms (sequel_pg). Memory allocations drop by 50-80% because there are no intermediate string objects — the C code converts PostgreSQL wire format directly to Ruby integers, dates, and strings in a single pass.

sequel_pg — benchmark numbers
# Benchmark: fetching 10,000 rows with and without sequel_pg
#
# Pure Ruby (pg gem only):
#   Fetching: 178ms
#   Memory:   14.2 MB allocated
#   GC runs:  3
#
# With sequel_pg:
#   Fetching: 58ms
#   Memory:   3.1 MB allocated
#   GC runs:  0
#
# The GC difference matters most. Ruby's garbage collector
# is stop-the-world — when it runs, your request pauses.
# Fewer allocations means fewer pauses means more consistent
# latency. Not just faster on average, but faster at p99.

The GC impact deserves particular attention. Ruby's garbage collector is stop-the-world — when it runs, your entire application pauses. Every intermediate object allocated during row fetching is an object the GC must eventually collect. With pure Ruby parsing, fetching 10,000 rows triggers three GC runs. With sequel_pg, it triggers zero. This does not merely improve average latency. It improves tail latency — the p99 that your users actually feel when they happen to hit a request that coincides with garbage collection.

There is no configuration. No API change. You add one require line and every query gets faster. This is the kind of optimization that makes you suspicious — surely it cannot be that simple. It can. Jeremy Evans (Sequel's author) wrote the C extension specifically for this purpose, and it has been stable for over a decade.

I should note the honest limitation: sequel_pg requires a C compiler at install time, which means it does not work on JRuby or TruffleRuby. If you are running on the JVM, you are using JDBC, and the row-fetching path is different entirely. For CRuby users — which is the vast majority — there is no reason not to install it.

Why Sequel is faster than ActiveRecord at the architectural level

sequel_pg explains the fetching speed. But the architectural advantages run deeper, and they explain the performance differences that persist even in operations where sequel_pg is not the bottleneck.

ActiveRecord overhead — what happens behind the scenes
# ActiveRecord — the same query
orders = Order
  .where(status: 'pending')
  .where('created_at >= ?', '2025-01-01')
  .order(created_at: :desc)
  .limit(50)

# Generated SQL is nearly identical. But ActiveRecord:
#   1. Instantiates full model objects with callbacks, dirty tracking, etc.
#   2. Allocates attribute hashes for every column, even those you won't read
#   3. Builds association proxy objects for every defined relationship
#
# Sequel returns plain hashes by default — no ceremony.
# When you do want models, Sequel's are lightweight.

ActiveRecord instantiates heavy model objects by default. Every row becomes a full ActiveRecord::Base instance with dirty tracking, callback chains, association proxies, attribute method definitions, and type casting infrastructure — whether you need any of it or not. The cost is approximately 45 object allocations per row.

Consider what "dirty tracking" means in practice. ActiveRecord stores the original values of every column so that when you call .save, it can generate an UPDATE that touches only the changed columns. This is a useful feature. It is also a feature you pay for on every SELECT, even when 95% of your queries are read-only and will never call .save. The original values are stored. The mutation tracking infrastructure is initialized. The cost is paid. Every row. Every query.

Sequel takes the opposite approach. By default, queries return plain Ruby hashes. When you do use Sequel models, they are lightweight — around 12 allocations per instance. Features are loaded via an explicit plugin system.

Sequel's plugin system — opt-in, not opt-out
# Sequel's plugin system — opt-in, not opt-out
#
# ActiveRecord loads everything by default:
#   callbacks, validations, dirty tracking, serialization,
#   attribute methods, autosave associations...
#   Whether you use them or not. For every model.
#
# Sequel loads nothing by default. You opt in:

class Order < Sequel::Model
  plugin :timestamps          # only if you need created_at/updated_at
  plugin :validation_helpers  # only if you validate in Ruby
  plugin :tactical_eager_loading
  plugin :pg_auto_parameterize

  many_to_one :customer
  one_to_many :line_items

  def validate
    super
    validates_presence [:customer_id, :status]
    validates_includes ['pending', 'shipped', 'delivered'], :status
  end
end

# Object instantiation cost comparison (Ruby 3.3, YJIT enabled):
#   ActiveRecord::Base subclass:  ~45 allocations per instance
#   Sequel::Model subclass:       ~12 allocations per instance
#
# When you fetch 10,000 rows, that is 450,000 vs 120,000 allocations.
# GC pressure drops proportionally.

This is not a minor architectural detail. It is the fundamental reason Sequel uses 4.4x less memory per row. You pay for what you use. ActiveRecord charges you for everything it offers, on every single row, regardless of whether you read a column or touch an association.

Notable Sequel plugins
# A selection of Sequel plugins worth knowing
#
# Performance:
#   :tactical_eager_loading   — automatic N+1 prevention
#   :dataset_associations     — filter associations without loading them
#   :static_cache             — cache entire small tables in memory
#   :pg_auto_parameterize     — automatic query parameterization
#
# Data integrity:
#   :auto_validations         — infer validations from DB constraints
#   :constraint_validations   — store validations as DB constraints
#   :timestamps               — automatic created_at/updated_at
#
# Serialization:
#   :json_serializer          — to_json with field selection
#   :xml_serializer           — to_xml with field selection
#   :serialization            — custom column serializers
#
# Behaviour:
#   :dirty                    — track column changes (opt-in, not default!)
#   :hooks                    — before_save, after_create, etc.
#   :nested_attributes        — accept nested hashes for associations
#   :tree                     — parent/children tree structures
#
# There are over 90 built-in plugins. You use exactly the ones you need.

The plugin system has a secondary benefit that is easy to overlook: it makes Sequel's behaviour predictable. When a Sequel model includes plugin :dirty, you know it tracks changes. When it does not, you know it does not. With ActiveRecord, every model tracks changes whether you asked for it or not, and understanding which behaviours are active requires understanding the entirety of ActiveRecord::Base. That is a substantial surface area to hold in your head.

Virtual row blocks and the query DSL: PostgreSQL without SQL strings

One of ActiveRecord's persistent frustrations is the boundary between its query builder and raw SQL. Simple queries compose beautifully — .where(status: 'pending') is clean and clear. But the moment you need a function call, a window function, a type cast, or a complex expression, you reach for a string: .where('EXTRACT(month FROM created_at) = ?', 3).

Strings are not composable. Strings are not inspectable. Strings are SQL injection vulnerabilities waiting for a careless interpolation. ActiveRecord's Arel provides a programmatic alternative, but it is explicitly marked as an internal API — not for public use, subject to change without notice.

Sequel solves this with virtual row blocks.

Virtual row blocks — Ruby syntax for complex SQL
# Virtual row blocks — expressive queries without SQL strings

# ActiveRecord forces you into string interpolation for anything complex:
Order.where('total > ? AND created_at >= ?', 100, Date.today - 7)
Order.where('EXTRACT(month FROM created_at) = ?', 3)

# Sequel's virtual row blocks give you Ruby syntax for SQL expressions:
DB[:orders].where { total > 100 }
DB[:orders].where { (total > 100) & (created_at >= Date.today - 7) }
DB[:orders].where { Sequel.extract(:month, created_at) =~ 3 }

# Window functions — try doing this in ActiveRecord without raw SQL:
DB[:orders]
  .select(:id, :customer_id, :total)
  .select_append {
    row_number.function.over(
      partition: :customer_id,
      order: Sequel.desc(:total)
    ).as(:rank)
  }
# SELECT "id", "customer_id", "total",
#   ROW_NUMBER() OVER (PARTITION BY "customer_id" ORDER BY "total" DESC) AS "rank"
# FROM "orders"

# Lateral joins, CTEs, CASE expressions — all available in Ruby syntax.
# No raw SQL strings. No SQL injection risk. Full composability.

Window functions deserve special attention here. ROW_NUMBER() OVER (PARTITION BY ...) is one of PostgreSQL's most powerful features for pagination, ranking, deduplication, and running totals. In ActiveRecord, using a window function means writing raw SQL. In Sequel, it is composable Ruby code that integrates with the rest of the dataset chain.

The same applies to Common Table Expressions — the WITH clause that makes complex queries readable by breaking them into named steps.

CTEs — first-class in Sequel
# Common Table Expressions (CTEs) — first-class in Sequel

# Find customers whose total spending exceeds the average
big_spenders = DB[:orders]
  .select_group(:customer_id)
  .select_append { sum(total).as(:customer_total) }

DB[:customers]
  .with(:big_spenders, big_spenders)
  .join(:big_spenders, customer_id: :id)
  .where { customer_total > DB[:big_spenders].select { avg(customer_total) } }
  .select_all(:customers)
  .select_append(Sequel[:big_spenders][:customer_total])

# WITH "big_spenders" AS (
#   SELECT "customer_id", sum("total") AS "customer_total"
#   FROM "orders" GROUP BY "customer_id"
# )
# SELECT "customers".*, "big_spenders"."customer_total"
# FROM "customers"
# INNER JOIN "big_spenders" ON ("big_spenders"."customer_id" = "customers"."id")
# WHERE ("customer_total" > (SELECT avg("customer_total") FROM "big_spenders"))

# ActiveRecord added CTE support in Rails 7.1 via .with().
# Sequel has supported CTEs since 2011.

I am not arguing that Sequel's DSL is always more readable than SQL. For simple queries, the SQL is often clearer. But the DSL provides something SQL strings cannot: composability. You can build a CTE dataset in one method, add filters in another, apply ordering in a third, and the result is a single well-formed query. With raw SQL strings, each composition step is string concatenation — fragile, error-prone, and impossible to inspect programmatically.

Streaming and cursors: processing millions of rows without the OOM

Every application eventually needs to process a large dataset. Export all orders to CSV. Recompute analytics. Backfill a new column. Run a data migration. The naive approach — Order.all.each — loads everything into memory and crashes when the dataset outgrows available RAM. I have witnessed this more times than I care to admit. The crash always happens in production. It always happens on the largest table. And it always happens on a Friday.

ActiveRecord's solution is find_each, which uses OFFSET/LIMIT pagination. It works, but it carries a cost that most developers do not realize until they profile it.

Why cursors beat OFFSET/LIMIT at scale
# Why cursors beat OFFSET/LIMIT at scale
#
# Manual OFFSET/LIMIT pagination with 2 million rows, batch size 1000:
#   Batch 1:     SELECT * FROM events LIMIT 1000 OFFSET 0       -- scans 1,000 rows
#   Batch 2:     SELECT * FROM events LIMIT 1000 OFFSET 1000    -- scans 2,000 rows
#   Batch 100:   SELECT * FROM events LIMIT 1000 OFFSET 99000   -- scans 100,000 rows
#   Batch 2000:  SELECT * FROM events LIMIT 1000 OFFSET 1999000 -- scans 2,000,000 rows
#
# Total row scans: 1000 + 2000 + 3000 + ... + 2000000
#   = roughly 2 BILLION row accesses for 2 million rows
#
# Sequel paged_each with cursors:
#   Total row scans: exactly 2,000,000
#   Each row read once. Period.
#
# Note: ActiveRecord's find_each uses keyset pagination (WHERE id > last_id),
# which avoids this problem when iterating by primary key. But for non-primary-key
# ordering, manual OFFSET/LIMIT is common — and that's where this cost hits.
# Sequel's cursor approach works efficiently regardless of column ordering.

The mathematics are unflattering. For a table with 2 million rows fetched in batches of 1,000, ActiveRecord's find_each executes 2,000 queries. Each query scans progressively deeper into the table because OFFSET does not skip rows — it reads and discards them. The total number of rows scanned is the sum of 1,000 + 2,000 + 3,000 + ... + 2,000,000. That sum is approximately one trillion. For two million actual rows.

Sequel's paged_each uses PostgreSQL cursors. Each row is read exactly once. No rescanning. No progressive slowdown.

paged_each — cursor-based streaming
# Streaming large result sets — never load everything into memory
DB = Sequel.connect('postgres://localhost/myapp')

# Bad: loads 2 million rows into memory at once
all_events = DB[:events].all  # => Array of 2M hashes. OOM incoming.

# Good: stream with paged_each (cursor-based iteration)
DB[:events].paged_each(rows_per_fetch: 5000) do |event|
  process(event)
end

# paged_each uses PostgreSQL cursors internally:
#   DECLARE sequel_cursor NO SCROLL CURSOR WITHOUT HOLD FOR
#     SELECT * FROM "events"
#   FETCH FORWARD 5000 FROM sequel_cursor
#   FETCH FORWARD 5000 FROM sequel_cursor
#   ...
#   CLOSE sequel_cursor
#
# Memory usage stays flat regardless of table size.
# ActiveRecord's find_each uses keyset pagination by primary key,
# which is efficient — but Sequel's cursor approach works for any
# column ordering, reading each row exactly once.

The performance difference scales with table size. At 100,000 rows, find_each is maybe 30% slower — noticeable but tolerable. At 2 million rows, it is 3-5x slower. At 10 million rows, the quadratic rescanning makes find_each essentially unusable, while paged_each maintains constant throughput.

I should be honest about the trade-off. Cursors hold a database connection for the entire iteration. If your processing takes hours, that connection is occupied for hours. With OFFSET/LIMIT, each batch is an independent query — the connection is returned to the pool between batches. For extremely long-running batch jobs where connection availability is constrained, the OFFSET/LIMIT approach has a genuine advantage. But for most batch processing — minutes, not hours — cursors are unambiguously better.

ActiveRecord 7.1+ quietly added cursor support via find_each(cursor: true). Credit where it is due. But Sequel has used cursors by default in paged_each for over a decade. The feature was not an afterthought.

pg_auto_parameterize: taming the plan cache

Here is a problem most Ruby developers do not know they have. It does not cause errors. It does not cause crashes. It silently degrades your monitoring and inflates your database's memory consumption, and you may never notice unless you look at pg_stat_statements directly.

Every time ActiveRecord sends a query with literal values, PostgreSQL sees a unique query string. SELECT * FROM users WHERE email = 'ada@example.com' and SELECT * FROM users WHERE email = 'grace@example.com' are two different queries as far as PostgreSQL's plan cache and pg_stat_statements are concerned. Two cached plans. Two entries in your monitoring. Two sets of statistics that could be one.

The plan cache explosion problem
# The plan cache problem in practice
#
# Without parameterization, pg_stat_statements fills up:
#
# SELECT * FROM users WHERE email = 'ada@example.com'    -- plan #1
# SELECT * FROM users WHERE email = 'grace@example.com'  -- plan #2
# SELECT * FROM users WHERE email = 'alan@example.com'   -- plan #3
# ...
# SELECT * FROM users WHERE email = 'user_99999@...'     -- plan #99,999
#
# pg_stat_statements.max defaults to 5,000. Once exceeded,
# PostgreSQL starts evicting entries. Your monitoring tools
# see queries appearing and disappearing. Performance trends
# become impossible to track.
#
# With pg_auto_parameterize, there is ONE entry:
# SELECT * FROM users WHERE email = $1
#   calls: 99,999  |  mean_time: 0.12ms  |  total_time: 11,999ms
#
# One line tells you everything. That is what clean monitoring looks like.

With 100,000 unique email lookups per day, you have 100,000 entries competing for space in pg_stat_statements. Since pg_stat_statements.max defaults to 5,000, entries are evicted constantly. Your monitoring tools show queries appearing and disappearing. You cannot track performance trends because the query you were watching was evicted and re-created with fresh statistics. The dashboard is chaos.

pg_auto_parameterize — automatic parameterization
# pg_auto_parameterize — automatic prepared statements
DB = Sequel.connect('postgres://localhost/myapp',
  extensions: [:pg_auto_parameterize])

# Without pg_auto_parameterize:
DB[:users].where(email: 'ada@example.com').first
# => SELECT * FROM "users" WHERE ("email" = 'ada@example.com') LIMIT 1
# Each unique email produces a separate query plan in pg_stat_statements.
# 10,000 different emails = 10,000 cached plans.

# With pg_auto_parameterize:
DB[:users].where(email: 'ada@example.com').first
# => SELECT * FROM "users" WHERE ("email" = $1) LIMIT 1   [$1 = 'ada@example.com']
# Every lookup shares one cached plan.
# pg_stat_statements stays clean. Plan cache stays small.
# PgBouncer in transaction mode works properly (no named prepared statements).

Sequel's pg_auto_parameterize extension automatically converts literal values to bound parameters. Every email lookup becomes SELECT * FROM "users" WHERE ("email" = $1). One plan. One pg_stat_statements entry. Clean monitoring. Efficient plan caching. One line in your dashboard that tells you the mean time, the total time, and the call count for every email lookup your application has ever performed.

There is an additional benefit for connection poolers like PgBouncer. Named prepared statements (the PostgreSQL PREPARE/EXECUTE pattern) are bound to a specific database connection, which breaks PgBouncer's transaction pooling mode. pg_auto_parameterize uses protocol-level parameterization instead — the parameters travel with the query in the same message. No named statements. Full PgBouncer compatibility.

ActiveRecord's approach to this problem is inconsistent. Some query shapes produce parameterized queries, others do not. Arel-generated queries tend to use bind parameters; raw SQL fragments do not. The result is a mix of parameterized and literal queries in your pg_stat_statements, which is arguably worse than consistent literal queries because the inconsistency makes monitoring unreliable. Sequel's extension handles all queries uniformly.

pg_array_associations: the join table you do not need

Many-to-many relationships in ActiveRecord require a join table. articles_tags, users_roles, posts_categories — tables that exist solely to connect two IDs. Each one needs its own indexes, its own vacuuming, its own disk space, its own dead tuple management. Each query through them requires an additional JOIN. Each write to them involves an additional INSERT or DELETE.

PostgreSQL has native array types. They have been stable and well-supported since PostgreSQL 8.3 — released in 2008. Sequel has an extension that puts them to work.

pg_array_associations — arrays instead of join tables
# pg_array_associations — eliminate join tables entirely
#
# Traditional many-to-many with a join table:
#   users -> users_tags -> tags
#   3 tables, 2 joins, index overhead on the join table.
#
# With pg_array_associations:
#   users (with tag_ids integer[] column) -> tags
#   2 tables, 1 GIN-indexed array, no join table.

Sequel.migration do
  change do
    add_column :articles, :tag_ids, 'integer[]', default: '{}'
    # CREATE INDEX articles_tag_ids_idx ON articles USING GIN (tag_ids);
  end
end

class Article < Sequel::Model
  plugin :pg_array_associations
  pg_array_to_many :tags, key: :tag_ids
end

class Tag < Sequel::Model
  plugin :pg_array_associations
  many_to_pg_array :articles, key: :tag_ids
end

# Usage:
article.tags              # => [#<Tag id=1>, #<Tag id=3>]
tag.articles              # => all articles with this tag's id in tag_ids

# One query, no join table:
# SELECT * FROM "tags" WHERE ("id" IN (1, 3))
#
# vs. ActiveRecord's join table approach:
# SELECT "tags".* FROM "tags"
#   INNER JOIN "articles_tags" ON "tags"."id" = "articles_tags"."tag_id"
#   WHERE "articles_tags"."article_id" = 7

The advantages compound. No join table to maintain, index, or vacuum. One fewer JOIN per query. GIN indexes on array columns are fast for containment queries. And the data model is more honest — an article's tags are stored on the article, not scattered across a separate table that exists for no reason other than relational orthodoxy.

Querying with pg_array — PostgreSQL-native operations
# Querying with pg_array — expressive and PostgreSQL-native
DB.extension :pg_array

# Find articles tagged with tag 5
Article.where(Sequel.pg_array_op(:tag_ids).contains([5]))
# SELECT * FROM articles WHERE tag_ids @> ARRAY[5]

# Find articles tagged with ALL of these tags
Article.where(Sequel.pg_array_op(:tag_ids).contains([5, 12, 7]))
# SELECT * FROM articles WHERE tag_ids @> ARRAY[5,12,7]

# Find articles tagged with ANY of these tags
Article.where(Sequel.pg_array_op(:tag_ids).overlaps([5, 12, 7]))
# SELECT * FROM articles WHERE tag_ids && ARRAY[5,12,7]

# Count tags per article
Article.select_append(Sequel.function(:array_length, :tag_ids, 1).as(:tag_count))
# SELECT *, array_length(tag_ids, 1) AS tag_count FROM articles

# All of these use GIN indexes. All are single queries.
# No join tables were harmed in the making of these queries.

The query capabilities go beyond simple lookups. Containment queries (@>), overlap queries (&&), array length calculations — all expressed in Sequel's Ruby DSL, all backed by GIN indexes, all executed as single queries with no joins.

Now for the honest counterpoint, because this feature has genuine limitations.

No referential integrity. PostgreSQL does not support foreign key constraints on individual array elements. If you delete a tag, articles referencing that tag's ID will have a stale value in their tag_ids array. You must handle cleanup in application code or with a trigger. A join table with a foreign key constraint handles this automatically.

Array size limits. If your many-to-many relationship can grow to thousands of entries per row — a user with 5,000 roles, an article with 10,000 tags — the array approach becomes inefficient. GIN indexes handle containment queries well, but updating a large array (append, remove) requires rewriting the entire array value. Join tables handle large fan-out more gracefully.

No attributes on the relationship. If the relationship carries its own data — a users_roles table with assigned_at, assigned_by, expires_at — you need a table. Arrays store values, not records.

For pure ID-to-ID mappings where the fan-out is moderate — tags, categories, labels, permissions — pg_array_associations is cleaner and faster. For everything else, the join table remains the correct choice.

"Most application developers interact with PostgreSQL through an ORM that exposes perhaps 10-15% of what PostgreSQL actually offers."

— from You Don't Need Redis, Chapter 1: Good Evening. We Have a Problem.

tactical_eager_loading: N+1 prevention without the ceremony

The N+1 query problem is the single most common ORM performance issue, and every framework has the same solution: explicit eager loading. In Rails, it is .includes(:customer). In Django, .select_related('customer'). In Eloquent, ->with('customer'). You must remember to add it. You must remember to add it in every controller, every serializer, every background job, every service object that touches associations. Forget it once, and you have an N+1.

The failure mode is particularly insidious because it works correctly in development — where your dataset has 5 orders — and fails in production — where it has 5,000. The N+1 is invisible until it matters.

Sequel's tactical_eager_loading plugin takes a fundamentally different approach. It detects the N+1 at runtime and fixes it automatically.

tactical_eager_loading — automatic N+1 prevention
# tactical_eager_loading — automatic N+1 prevention
#
# ActiveRecord approach: you must remember to call .includes(:customer)
# Forget it once, and you have an N+1. In every controller. Forever.
#
# Sequel approach with tactical_eager_loading:

class Order < Sequel::Model
  plugin :tactical_eager_loading
  many_to_one :customer
  one_to_many :line_items
end

# When you load orders as a dataset (collection):
orders = Order.where(status: 'pending').all

orders.each do |order|
  order.customer  # First access triggers eager load for ALL orders
end

# What happens behind the scenes:
#   1. First order.customer call detects this Order was loaded as part of a set
#   2. Instead of loading just this customer, it loads customers for ALL orders
#   3. Subsequent order.customer calls hit the in-memory cache
#
# SQL executed:
#   SELECT * FROM "orders" WHERE ("status" = 'pending')
#   SELECT * FROM "customers" WHERE ("id" IN (42, 17, 89, ...))
#
# Two queries total. Not 201. Without you writing .includes anywhere.
# The plugin handles it automatically at the model level.

The mechanism is straightforward. When you load a collection of models via a dataset (e.g., Order.where(...).all), Sequel remembers which models were loaded together. When you access an association on one of them, the plugin checks: "Was this model loaded as part of a set?" If yes, it eager-loads the association for all models in the set, not just the one you asked about.

The result is the same SQL as explicit eager loading — two queries instead of N+1 — without requiring you to declare the eager load at every call site. You enable the plugin once on the model, and every access pattern is covered.

tactical vs explicit — when to use which
# tactical_eager_loading vs explicit eager loading — edge cases

# Explicit eager loading (Sequel supports this too):
orders = Order.eager(:customer, :line_items).where(status: 'pending').all
# Always loads customer and line_items, even if you don't access them.

# Tactical eager loading:
orders = Order.where(status: 'pending').all
orders.each do |order|
  order.customer     # triggers eager load of ALL customers
  order.line_items   # triggers eager load of ALL line_items
end
# Only loads associations you actually access.

# When tactical loses to explicit:
#   1. If you KNOW you need the associations, explicit avoids the
#      first-access detection overhead (trivial, but real)
#   2. Nested eager loading: Order.eager(customer: :address)
#      loads 3 levels in advance. Tactical handles this too,
#      but each level triggers on first access.
#   3. If you access associations conditionally (only for some records),
#      tactical may over-fetch by loading for all records.

# The pragmatic approach: use tactical as the default safety net,
# explicit when you know the exact shape of data you need.

This is, to be direct, better than the Rails approach. It is not just more convenient. It eliminates an entire class of performance bugs that exist solely because a developer forgot to type .includes in one of fifty places. Rails has strict_loading mode, which raises an error when a lazy load occurs — but that is a detective, not a solution. It tells you the problem exists. It does not fix it. Sequel's tactical eager loading is both the detection and the fix.

The honest caveat: tactical eager loading can over-fetch. If you load 200 orders but only access order.customer on 10 of them (perhaps in a conditional branch), the plugin still loads all 200 customers. Explicit eager loading has the same problem — .includes(:customer) also loads all 200 customers regardless. The over-fetching is a property of batch eager loading itself, not of Sequel's implementation. For conditional access patterns, you may want to use lazy loading deliberately and accept the individual queries.

Connection pooling: built in and quietly excellent

Connection pooling is one of those features that receives very little attention until it goes wrong — at which point it receives your full, undivided, 3am attention. ActiveRecord's connection pool has improved substantially over the years, but it still carries overhead that Sequel avoids by design.

Connection pooling — built-in and lightweight
# Sequel's connection pool — built-in, well-tuned
DB = Sequel.connect('postgres://localhost/myapp',
  max_connections: 10,        # pool size
  pool_timeout: 5,            # seconds to wait for a connection
  extensions: [:pg_auto_parameterize])

# Sequel's threaded connection pool is production-grade.
# No external gems needed (unlike ActiveRecord + connection_pool hacks).

# Connection checkout is ~0.001ms. Sequel does not
# run a healthcheck query on every checkout — it trusts the TCP socket
# and handles disconnects lazily via retry logic.
#
# Compare to ActiveRecord's checkout:
#   1. Acquire mutex
#   2. Run SELECT 1 healthcheck (if verify_active_connection is true)
#   3. Clear query cache
#   4. Reset session variables
#   That is 4 steps vs. 1.

Sequel's connection checkout is approximately 0.001ms — it acquires a mutex and returns a connection. ActiveRecord's checkout involves mutex acquisition, an optional healthcheck query (SELECT 1), query cache clearing, and session variable resetting. Four steps versus one.

The healthcheck query deserves particular scrutiny. Running SELECT 1 on every checkout is defensive programming against stale connections — a connection that was valid ten minutes ago may have been closed by a firewall, a proxy, or a PostgreSQL timeout. The problem is that this check adds a round trip to every database interaction. If your application makes 100 queries per request, that is 100 additional round trips per request.

Advanced connection pool configuration
# Advanced connection pool configuration
DB = Sequel.connect(ENV['DATABASE_URL'],
  max_connections: 15,
  pool_timeout: 5,
  # Sequel offers multiple pool types:
  #   :threaded      — default, one connection per thread
  #   :sharded       — route queries to different database servers
  #   :timed_queue   — fair queuing with timeout, better under high contention
  pool_class: Sequel::ThreadedConnectionPool)

# Health checking without the overhead:
DB.extension :connection_validator
DB.pool.connection_validation_timeout = 30
# Only validates connections idle for > 30 seconds.
# Active connections skip the check entirely.
# This is dramatically smarter than SELECT 1 on every checkout.

# Connection lifecycle hooks:
DB.after_connect do |conn|
  conn.exec("SET statement_timeout = '5s'")
  conn.exec("SET lock_timeout = '3s'")
end
# Runs once when a connection is created, not on every checkout.

Sequel's connection validator extension takes a more measured approach. It validates connections only when they have been idle for longer than a configurable threshold (defaulting to 30 seconds). Active connections — the ones most likely to be valid — skip the check entirely. This is the right trade-off. You catch stale connections without paying the round-trip cost on every checkout.

The after_connect hook is worth noting as well. It runs once when a connection is created, not on every checkout. This is the correct place to set session-level defaults — statement_timeout, lock_timeout, search_path — because these settings persist for the life of the connection. ActiveRecord provides similar hooks, but they are less discoverable and less commonly used.

Migrations: PostgreSQL as a first-class citizen

Sequel's migration DSL understands PostgreSQL natively. Array columns, GIN indexes, jsonb, citext, ltree, range types — they are all first-class citizens, not afterthoughts bolted on with string-based column definitions.

Sequel migrations — native PostgreSQL type support
# Sequel migrations — clean, reversible, PostgreSQL-aware
Sequel.migration do
  change do
    create_table(:orders) do
      primary_key :id
      foreign_key :customer_id, :customers, null: false
      String :status, null: false, default: 'pending'
      BigDecimal :total, size: [12, 2], null: false
      column :tag_ids, 'integer[]', default: '{}'
      DateTime :created_at, null: false
      DateTime :updated_at, null: false

      index :customer_id
      index :status
      index :created_at
      index :tag_ids, type: :gin
    end
  end
end

# Sequel knows PostgreSQL types natively:
#   integer[], jsonb, hstore, citext, ltree, tsrange...
# No awkward workarounds. No "t.column :data, :jsonb" incantations.

ActiveRecord has improved here in recent versions. Rails 7+ handles jsonb, arrays, and most common PostgreSQL types without string workarounds. But Sequel has supported PostgreSQL-specific types for over a decade, and the support is deeper. Sequel can define exclusion constraints, partial indexes, expression indexes, and range types in its migration DSL. ActiveRecord requires execute blocks for most of these — which means dropping into raw SQL, losing reversibility, and breaking the abstraction that migrations are supposed to provide.

For teams committed to PostgreSQL — not maintaining compatibility with MySQL or SQLite — Sequel's migrations are significantly more expressive.

The complete setup: putting the extensions together

Individual features are interesting. The compound effect is what makes Sequel exceptional for PostgreSQL workloads. Here is a production configuration that combines every optimization discussed above.

A complete production Sequel setup
# Pulling it all together — a production Sequel setup
require 'sequel'
require 'sequel_pg'

DB = Sequel.connect(ENV['DATABASE_URL'],
  max_connections: 15,
  extensions: [
    :pg_auto_parameterize,   # automatic prepared statements
    :pg_array,               # native array type support
    :pg_json,                # native jsonb support
    :pagination,             # dataset pagination
  ])

class Article < Sequel::Model
  plugin :tactical_eager_loading
  plugin :timestamps, update_on_create: true
  plugin :pg_array_associations

  pg_array_to_many :tags, key: :tag_ids
  many_to_one :author, class: :User

  dataset_module do
    def published
      where(published: true).order(Sequel.desc(:created_at))
    end

    def by_tag(tag_id)
      where(Sequel.pg_array_op(:tag_ids).contains([tag_id]))
    end
  end
end

# API endpoint — fast, memory-efficient, N+1-proof:
articles = Article.published.by_tag(5).paginate(1, 20)

articles.each do |article|
  puts "#{article.title} by #{article.author.name}"
  puts "Tags: #{article.tags.map(&:name).join(', ')}"
end

# Total queries: 3 (articles + authors + tags)
# Memory: minimal — tactical_eager_loading handles associations
# Parameters: auto-parameterized for plan cache efficiency

Notice what is absent. No eager loading declarations at the call site. No special memory-management code for large datasets. No manual parameterization. No join tables for simple tag associations. The extensions handle all of it. The application code reads like a description of what you want, not a negotiation with the ORM about how to get it.

The total queries for the example above: three. Articles, authors, and tags. The total memory: minimal, because each model object carries only the plugins you opted into. The plan cache: clean, because every query is auto-parameterized. The N+1 queries: zero, because tactical eager loading handles associations on first access.

This is what "performance by default" looks like when the default is well-chosen.

When ActiveRecord is still the right choice

I have standards, but I also have honesty. A waiter who overstates his case is no waiter at all. ActiveRecord is the right choice in several scenarios, and pretending otherwise would be a disservice to you.

You are building a Rails application and do not want to swim upstream. Rails conventions assume ActiveRecord. Gems assume ActiveRecord. Devise, Pundit, Active Admin, Sidekiq — the entire ecosystem is built on the assumption that your models inherit from ActiveRecord::Base. Using Sequel in Rails is possible (the sequel-rails gem exists), but you will spend time on integration that could be spent on features. You will encounter gems that do not work. You will find Stack Overflow answers that do not apply. The ecosystem friction is real.

Your application is not performance-sensitive. Admin panels, internal tools, low-traffic CRUD applications — the 2-7x overhead is measured in milliseconds. If your p99 latency budget is 500ms and your queries take 3ms instead of 1ms, the difference is invisible to users. Not every application needs to be fast. Some need to be correct, maintainable, and shipped quickly. ActiveRecord excels at all three.

Your team knows ActiveRecord and has no appetite for learning. Sequel's API is different. Not harder — different. .where exists in both, but dataset chaining, virtual row blocks, the plugin system, and the extension architecture require learning. If your team ships faster with ActiveRecord and performance is adequate, that is a valid choice. I have opinions about adequate versus excellent, but I also have respect for teams that know their own constraints.

You need multi-database support. If your application must work with PostgreSQL, MySQL, and SQLite, ActiveRecord's adapter system is more battle-tested for cross-database compatibility. Sequel supports multiple databases, but its greatest strengths — the PostgreSQL extensions, array types, CTEs, window functions via the DSL — are PostgreSQL-specific. If you cannot commit to PostgreSQL, some of Sequel's most compelling features become unavailable.

For everything else — applications where query performance matters, where you are processing large datasets, where memory pressure is a concern, where you want your ORM to stay out of the way rather than wrapping everything in ceremony — Sequel is the better tool. And for applications committed to PostgreSQL specifically, it is not close.

Migrating from ActiveRecord to Sequel: the practical path

If I have made my case and you are considering the move, allow me to counsel patience. A full ActiveRecord-to-Sequel migration is rarely advisable as a single effort. The responsible approach is incremental.

Migrating from ActiveRecord to Sequel — incrementally
# Migrating from ActiveRecord to Sequel — the practical path
#
# Step 1: Run both ORMs side by side (yes, this works)
# Gemfile:
#   gem 'activerecord'
#   gem 'sequel'
#   gem 'sequel_pg'
#
# config/initializers/sequel.rb:
SEQUEL_DB = Sequel.connect(
  ENV['DATABASE_URL'],
  max_connections: 5,
  extensions: [:pg_auto_parameterize]
)

# Step 2: Move read-heavy endpoints first
# These benefit most from Sequel's lighter object model.
# API list endpoints, reports, data exports — anything
# that fetches many rows and doesn't write.

# Step 3: Move write-heavy endpoints last
# If you rely on ActiveRecord callbacks, these need
# the most refactoring. Sequel has hooks, but they
# work differently — and honestly, that's often an
# improvement. Explicit > magic.

# Step 4: Remove ActiveRecord when nothing depends on it
# Or don't. Some teams run both permanently, using Sequel
# for performance-critical paths and AR for admin/CRUD.

The key insight is that Sequel and ActiveRecord can coexist in the same application. They connect to the same database. They use the same tables. They do not interfere with each other. You can move one endpoint at a time — starting with the read-heavy paths where Sequel's performance advantages are most visible.

Read-heavy endpoints benefit most because they fetch many rows and do not rely on ActiveRecord's write-side features — callbacks, validations, dirty tracking. A list API that fetches 200 orders with their customers and line items is the ideal candidate. The Sequel version will be faster, use less memory, and automatically prevent N+1 queries. The ActiveRecord version keeps working. You can compare them side by side.

Write-heavy endpoints should be migrated last, and with care. If your models depend heavily on ActiveRecord callbacks — before_save, after_create, after_commit — you will need to audit each callback and decide whether to replicate it as a Sequel hook, move it to a service object, or eliminate it entirely. In my experience, this audit often reveals callbacks that should not exist — callbacks that send emails, enqueue jobs, or update counters in ways that create surprising side effects. The migration becomes an opportunity to clean house.

Some teams never fully migrate, and that is fine. Sequel for the performance-critical API. ActiveRecord for the admin panel. Both are adults. They share the database without conflict.

A note on Jeremy Evans

It would be remiss not to mention the author. Jeremy Evans maintains Sequel, the Roda web framework, Rodauth (authentication), the sequel_pg C extension, and over a hundred other Ruby libraries — largely as a solo maintainer. The code quality is consistently excellent. The documentation is thorough. The commit history is meticulous. Bug reports receive responses within hours.

This matters for a practical reason: Sequel's development is focused and consistent. There is no design-by-committee, no feature bloat, no deprecation churn. The API has been stable for years. Code you wrote against Sequel five years ago still works today. For a production dependency, this kind of stewardship is worth more than a large contributor count.

It also means there is a single point of failure, which some teams consider a risk. I understand the concern. But I would note that ActiveRecord's effective development is also driven by a small core team within the Rails organization, and that project maintenance quality is not a function of team size but of maintainer discipline. On that metric, Sequel is exemplary.

What happens after you have optimized the ORM

Sequel gives you a faster ORM. It generates clean SQL, fetches results efficiently, prevents N+1 queries automatically, and uses PostgreSQL features that ActiveRecord ignores. The queries leaving your Ruby process are already good.

But the queries arriving at PostgreSQL still run against whatever indexes, statistics, and execution plans the database has available. A fast ORM sends efficient SQL — but if the right index does not exist, efficient SQL still scans the table. If a materialized view could answer a repeated aggregation in 0.1ms instead of 200ms, no ORM will create it for you. If your statistics are stale and the planner chooses a nested loop where a hash join would be 50x faster, no ORM can intervene.

The ORM controls what SQL is sent. The database controls how that SQL is executed. Optimizing one without the other is attending to the wardrobe while neglecting the foundation.

Gold Lapel sits between your Ruby application and PostgreSQL, observing every query as it crosses the wire. It does not care whether the SQL came from Sequel, ActiveRecord, or a hand-written string. What it sees are patterns: which queries run most often, which ones scan too many rows, which ones would benefit from an index that does not exist yet, which repeated aggregations could be materialized. The ORM optimizes the sender. Gold Lapel optimizes the receiver.

The best-dressed query is the one that arrives well-formed and finds its index waiting.

Frequently asked questions

Terms referenced in this article

Sequel's materialized view support is one of its quiet strengths. If you are considering using it, I have written a Ruby-specific chapter on materialized views in Ruby and Rails that covers the patterns, the refresh strategies, and the edge cases that documentation tends to leave as an exercise for the reader.